diff options
Diffstat (limited to 'mobile/android/geckoview/src/main/java/org/mozilla/gecko')
114 files changed, 23369 insertions, 0 deletions
diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AlarmReceiver.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AlarmReceiver.java new file mode 100644 index 000000000..a098113fa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AlarmReceiver.java @@ -0,0 +1,42 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.WrapForJNI; + +import android.app.IntentService; +import android.content.Context; +import android.content.Intent; +import android.content.BroadcastReceiver; +import android.os.PowerManager; +import android.os.PowerManager.WakeLock; +import android.util.Log; + +import java.util.Timer; +import java.util.TimerTask; + +public class AlarmReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + PowerManager powerManager = (PowerManager)context.getSystemService(Context.POWER_SERVICE); + final WakeLock wakeLock = powerManager.newWakeLock(PowerManager.SCREEN_DIM_WAKE_LOCK | PowerManager.ACQUIRE_CAUSES_WAKEUP, "GeckoAlarm"); + wakeLock.acquire(); + + AlarmReceiver.notifyAlarmFired(); + TimerTask releaseLockTask = new TimerTask() { + @Override + public void run() { + wakeLock.release(); + } + }; + Timer timer = new Timer(); + // 5 seconds ought to be enough for anybody + timer.schedule(releaseLockTask, 5 * 1000); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void notifyAlarmFired(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java new file mode 100644 index 000000000..54e1b0931 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java @@ -0,0 +1,425 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 java.util.ArrayList; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.GamepadUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.hardware.input.InputManager; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + + +public class AndroidGamepadManager { + // This is completely arbitrary. + private static final float TRIGGER_PRESSED_THRESHOLD = 0.25f; + private static final long POLL_TIMER_PERIOD = 1000; // milliseconds + + private static enum Axis { + X(MotionEvent.AXIS_X), + Y(MotionEvent.AXIS_Y), + Z(MotionEvent.AXIS_Z), + RZ(MotionEvent.AXIS_RZ); + + public final int axis; + + private Axis(int axis) { + this.axis = axis; + } + } + + // A list of gamepad button mappings. Axes are determined at + // runtime, as they vary by Android version. + private static enum Trigger { + Left(6), + Right(7); + + public final int button; + + private Trigger(int button) { + this.button = button; + } + } + + private static final int FIRST_DPAD_BUTTON = 12; + // A list of axis number, gamepad button mappings for negative, positive. + // Button mappings are added to FIRST_DPAD_BUTTON. + private static enum DpadAxis { + UpDown(MotionEvent.AXIS_HAT_Y, 0, 1), + LeftRight(MotionEvent.AXIS_HAT_X, 2, 3); + + public final int axis; + public final int negativeButton; + public final int positiveButton; + + private DpadAxis(int axis, int negativeButton, int positiveButton) { + this.axis = axis; + this.negativeButton = negativeButton; + this.positiveButton = positiveButton; + } + } + + private static enum Button { + A(KeyEvent.KEYCODE_BUTTON_A), + B(KeyEvent.KEYCODE_BUTTON_B), + X(KeyEvent.KEYCODE_BUTTON_X), + Y(KeyEvent.KEYCODE_BUTTON_Y), + L1(KeyEvent.KEYCODE_BUTTON_L1), + R1(KeyEvent.KEYCODE_BUTTON_R1), + L2(KeyEvent.KEYCODE_BUTTON_L2), + R2(KeyEvent.KEYCODE_BUTTON_R2), + SELECT(KeyEvent.KEYCODE_BUTTON_SELECT), + START(KeyEvent.KEYCODE_BUTTON_START), + THUMBL(KeyEvent.KEYCODE_BUTTON_THUMBL), + THUMBR(KeyEvent.KEYCODE_BUTTON_THUMBR), + DPAD_UP(KeyEvent.KEYCODE_DPAD_UP), + DPAD_DOWN(KeyEvent.KEYCODE_DPAD_DOWN), + DPAD_LEFT(KeyEvent.KEYCODE_DPAD_LEFT), + DPAD_RIGHT(KeyEvent.KEYCODE_DPAD_RIGHT); + + public final int button; + + private Button(int button) { + this.button = button; + } + } + + private static class Gamepad { + // ID from GamepadService + public int id; + // Retain axis state so we can determine changes. + public float axes[]; + public boolean dpad[]; + public int triggerAxes[]; + public float triggers[]; + + public Gamepad(int serviceId, int deviceId) { + id = serviceId; + axes = new float[Axis.values().length]; + dpad = new boolean[4]; + triggers = new float[2]; + + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + // LTRIGGER/RTRIGGER don't seem to be exposed on older + // versions of Android. + if (device.getMotionRange(MotionEvent.AXIS_LTRIGGER) != null && device.getMotionRange(MotionEvent.AXIS_RTRIGGER) != null) { + triggerAxes = new int[]{MotionEvent.AXIS_LTRIGGER, + MotionEvent.AXIS_RTRIGGER}; + } else if (device.getMotionRange(MotionEvent.AXIS_BRAKE) != null && device.getMotionRange(MotionEvent.AXIS_GAS) != null) { + triggerAxes = new int[]{MotionEvent.AXIS_BRAKE, + MotionEvent.AXIS_GAS}; + } else { + triggerAxes = null; + } + } + } + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void onGamepadChange(int id, boolean added); + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void onButtonChange(int id, int button, boolean pressed, float value); + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void onAxisChange(int id, boolean[] valid, float[] values); + + private static boolean sStarted; + private static final SparseArray<Gamepad> sGamepads = new SparseArray<>(); + private static final SparseArray<List<KeyEvent>> sPendingGamepads = new SparseArray<>(); + private static InputManager.InputDeviceListener sListener; + private static Timer sPollTimer; + + private AndroidGamepadManager() { + } + + @WrapForJNI + private static void start() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + doStart(); + } + }); + } + + /* package */ static void doStart() { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + scanForGamepads(); + addDeviceListener(); + sStarted = true; + } + } + + @WrapForJNI + private static void stop() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + doStop(); + } + }); + } + + /* package */ static void doStop() { + ThreadUtils.assertOnUiThread(); + if (sStarted) { + removeDeviceListener(); + sPendingGamepads.clear(); + sGamepads.clear(); + sStarted = false; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void onGamepadAdded(final int device_id, final int service_id) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + handleGamepadAdded(device_id, service_id); + } + }); + } + + /* package */ static void handleGamepadAdded(int deviceId, int serviceId) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return; + } + + final List<KeyEvent> pending = sPendingGamepads.get(deviceId); + if (pending == null) { + removeGamepad(deviceId); + return; + } + + sPendingGamepads.remove(deviceId); + sGamepads.put(deviceId, new Gamepad(serviceId, deviceId)); + // Handle queued KeyEvents + for (KeyEvent ev : pending) { + handleKeyEvent(ev); + } + } + + private static float deadZone(MotionEvent ev, int axis) { + if (GamepadUtils.isValueInDeadZone(ev, axis)) { + return 0.0f; + } + return ev.getAxisValue(axis); + } + + private static void mapDpadAxis(Gamepad gamepad, + boolean pressed, + float value, + int which) { + if (pressed != gamepad.dpad[which]) { + gamepad.dpad[which] = pressed; + onButtonChange(gamepad.id, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value)); + } + } + + public static boolean handleMotionEvent(MotionEvent ev) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return false; + } + + final Gamepad gamepad = sGamepads.get(ev.getDeviceId()); + if (gamepad == null) { + // Not a device we care about. + return false; + } + + // First check the analog stick axes + boolean[] valid = new boolean[Axis.values().length]; + float[] axes = new float[Axis.values().length]; + boolean anyValidAxes = false; + for (Axis axis : Axis.values()) { + float value = deadZone(ev, axis.axis); + int i = axis.ordinal(); + if (value != gamepad.axes[i]) { + axes[i] = value; + gamepad.axes[i] = value; + valid[i] = true; + anyValidAxes = true; + } + } + if (anyValidAxes) { + // Send an axismove event. + onAxisChange(gamepad.id, valid, axes); + } + + // Map triggers to buttons. + if (gamepad.triggerAxes != null) { + for (Trigger trigger : Trigger.values()) { + int i = trigger.ordinal(); + int axis = gamepad.triggerAxes[i]; + float value = deadZone(ev, axis); + if (value != gamepad.triggers[i]) { + gamepad.triggers[i] = value; + boolean pressed = value > TRIGGER_PRESSED_THRESHOLD; + onButtonChange(gamepad.id, trigger.button, pressed, value); + } + } + } + // Map d-pad to buttons. + for (DpadAxis dpadaxis : DpadAxis.values()) { + float value = deadZone(ev, dpadaxis.axis); + mapDpadAxis(gamepad, value < 0.0f, value, dpadaxis.negativeButton); + mapDpadAxis(gamepad, value > 0.0f, value, dpadaxis.positiveButton); + } + return true; + } + + public static boolean handleKeyEvent(KeyEvent ev) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return false; + } + + int deviceId = ev.getDeviceId(); + final List<KeyEvent> pendingGamepad = sPendingGamepads.get(deviceId); + if (pendingGamepad != null) { + // Queue up key events for pending devices. + pendingGamepad.add(ev); + return true; + } + + if (sGamepads.get(deviceId) == null) { + InputDevice device = ev.getDevice(); + if (device != null && + (device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + // This is a gamepad we haven't seen yet. + addGamepad(device); + sPendingGamepads.get(deviceId).add(ev); + return true; + } + // Not a device we care about. + return false; + } + + int key = -1; + for (Button button : Button.values()) { + if (button.button == ev.getKeyCode()) { + key = button.ordinal(); + break; + } + } + if (key == -1) { + // Not a key we know how to handle. + return false; + } + if (ev.getRepeatCount() > 0) { + // We would handle this key, but we're not interested in + // repeats. Eat it. + return true; + } + + Gamepad gamepad = sGamepads.get(deviceId); + boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN; + onButtonChange(gamepad.id, key, pressed, pressed ? 1.0f : 0.0f); + return true; + } + + private static void scanForGamepads() { + int[] deviceIds = InputDevice.getDeviceIds(); + if (deviceIds == null) { + return; + } + for (int i = 0; i < deviceIds.length; i++) { + InputDevice device = InputDevice.getDevice(deviceIds[i]); + if (device == null) { + continue; + } + if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) != InputDevice.SOURCE_GAMEPAD) { + continue; + } + addGamepad(device); + } + } + + private static void addGamepad(InputDevice device) { + sPendingGamepads.put(device.getId(), new ArrayList<KeyEvent>()); + onGamepadChange(device.getId(), true); + } + + private static void removeGamepad(int deviceId) { + Gamepad gamepad = sGamepads.get(deviceId); + onGamepadChange(gamepad.id, false); + sGamepads.remove(deviceId); + } + + private static void addDeviceListener() { + if (Versions.preJB) { + // Poll known gamepads to see if they've disappeared. + sPollTimer = new Timer(); + sPollTimer.scheduleAtFixedRate(new TimerTask() { + @Override + public void run() { + for (int i = 0; i < sGamepads.size(); ++i) { + final int deviceId = sGamepads.keyAt(i); + if (InputDevice.getDevice(deviceId) == null) { + removeGamepad(deviceId); + } + } + } + }, POLL_TIMER_PERIOD, POLL_TIMER_PERIOD); + return; + } + sListener = new InputManager.InputDeviceListener() { + @Override + public void onInputDeviceAdded(int deviceId) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device == null) { + return; + } + if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + addGamepad(device); + } + } + + @Override + public void onInputDeviceRemoved(int deviceId) { + if (sPendingGamepads.get(deviceId) != null) { + // Got removed before Gecko's ack reached us. + // gamepadAdded will deal with it. + sPendingGamepads.remove(deviceId); + return; + } + if (sGamepads.get(deviceId) != null) { + removeGamepad(deviceId); + } + } + + @Override + public void onInputDeviceChanged(int deviceId) { + } + }; + ((InputManager)GeckoAppShell.getContext().getSystemService(Context.INPUT_SERVICE)).registerInputDeviceListener(sListener, ThreadUtils.getUiHandler()); + } + + private static void removeDeviceListener() { + if (Versions.preJB) { + if (sPollTimer != null) { + sPollTimer.cancel(); + sPollTimer = null; + } + return; + } + ((InputManager)GeckoAppShell.getContext().getSystemService(Context.INPUT_SERVICE)).unregisterInputDeviceListener(sListener); + sListener = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java new file mode 100644 index 000000000..c4f64fd3d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/BaseGeckoInterface.java @@ -0,0 +1,169 @@ +/* -*- 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 org.mozilla.gecko.util.ActivityUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.Context; +import android.graphics.RectF; +import android.hardware.SensorEventListener; +import android.location.LocationListener; +import android.view.View; +import android.widget.AbsoluteLayout; + +public class BaseGeckoInterface implements GeckoAppShell.GeckoInterface { + // Bug 908744: Implement GeckoEventListener + // Bug 908752: Implement SensorEventListener + // Bug 908755: Implement LocationListener + // Bug 908756: Implement Tabs.OnTabsChangedListener + // Bug 908760: Implement GeckoEventResponder + + private final Context mContext; + private GeckoProfile mProfile; + private final EventDispatcher eventDispatcher; + + public BaseGeckoInterface(Context context) { + mContext = context; + eventDispatcher = new EventDispatcher(); + } + + @Override + public EventDispatcher getAppEventDispatcher() { + return eventDispatcher; + } + + @Override + public GeckoProfile getProfile() { + // Fall back to default profile if we didn't load a specific one + if (mProfile == null) { + mProfile = GeckoProfile.get(mContext); + } + return mProfile; + } + + @Override + public Activity getActivity() { + return (Activity)mContext; + } + + @Override + public String getDefaultUAString() { + return HardwareUtils.isTablet() ? AppConstants.USER_AGENT_FENNEC_TABLET : + AppConstants.USER_AGENT_FENNEC_MOBILE; + } + + // Bug 908775: Implement this + @Override + public void doRestart() {} + + @Override + public void setFullScreen(final boolean fullscreen) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + ActivityUtils.setFullScreen(getActivity(), fullscreen); + } + }); + } + + // Bug 908779: Implement this + @Override + public void addPluginView(final View view) {} + + // Bug 908781: Implement this + @Override + public void removePluginView(final View view) {} + + @Override + public void enableOrientationListener() {} + + @Override + public void disableOrientationListener() {} + + // Bug 908786: Implement this + @Override + public void addAppStateListener(GeckoAppShell.AppStateListener listener) {} + + // Bug 908787: Implement this + @Override + public void removeAppStateListener(GeckoAppShell.AppStateListener listener) {} + + // Bug 908789: Implement this + @Override + public void notifyWakeLockChanged(String topic, String state) {} + + @Override + public boolean areTabsShown() { + return false; + } + + // Bug 908791: Implement this + @Override + public AbsoluteLayout getPluginContainer() { + return null; + } + + @Override + public void notifyCheckUpdateResult(String result) { + GeckoAppShell.notifyObservers("Update:CheckResult", result); + } + + // Bug 908792: Implement this + @Override + public void invalidateOptionsMenu() {} + + @Override + public void createShortcut(String title, String URI) { + // By default, do nothing. + } + + @Override + public void checkUriVisited(String uri) { + // By default, no URIs are considered visited. + } + + @Override + public void markUriVisited(final String uri) { + // By default, no URIs are marked as visited. + } + + @Override + public void setUriTitle(final String uri, final String title) { + // By default, no titles are associated with URIs. + } + + @Override + public void setAccessibilityEnabled(boolean enabled) { + // By default, take no action when accessibility is toggled on or off. + } + + @Override + public boolean openUriExternal(String targetURI, String mimeType, String packageName, String className, String action, String title) { + // By default, never open external URIs. + return false; + } + + @Override + public String[] getHandlersForMimeType(String mimeType, String action) { + // By default, offer no handlers for any MIME type. + return new String[] {}; + } + + @Override + public String[] getHandlersForURL(String url, String action) { + // By default, offer no handlers for any URL. + return new String[] {}; + } + + @Override + public String getDefaultChromeURI() { + // By default, use the GeckoView-specific chrome URI. + return "chrome://browser/content/geckoview.xul"; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ContextGetter.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ContextGetter.java new file mode 100644 index 000000000..315854633 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ContextGetter.java @@ -0,0 +1,15 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.content.Context; +import android.content.SharedPreferences; + +public interface ContextGetter { + Context getContext(); + SharedPreferences getSharedPreferences(); +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java new file mode 100644 index 000000000..15df27336 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java @@ -0,0 +1,478 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 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.PrintWriter; +import java.io.StringWriter; +import java.util.Arrays; +import java.util.UUID; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Process; +import android.util.Log; + +public class CrashHandler implements Thread.UncaughtExceptionHandler { + + private static final String LOGTAG = "GeckoCrashHandler"; + private static final Thread MAIN_THREAD = Thread.currentThread(); + private static final String DEFAULT_SERVER_URL = + "https://crash-reports.mozilla.com/submit?id=%1$s&version=%2$s&buildid=%3$s"; + + // Context for getting device information + protected final Context appContext; + // Thread that this handler applies to, or null for a global handler + protected final Thread handlerThread; + protected final Thread.UncaughtExceptionHandler systemUncaughtHandler; + + protected boolean crashing; + protected boolean unregistered; + + /** + * Get the root exception from the 'cause' chain of an exception. + * + * @param exc An exception + * @return The root exception + */ + public static Throwable getRootException(Throwable exc) { + for (Throwable cause = exc; cause != null; cause = cause.getCause()) { + exc = cause; + } + return exc; + } + + /** + * Get the standard stack trace string of an exception. + * + * @param exc An exception + * @return The exception stack trace. + */ + public static String getExceptionStackTrace(final Throwable exc) { + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + exc.printStackTrace(pw); + pw.flush(); + return sw.toString(); + } + + /** + * Terminate the current process. + */ + public static void terminateProcess() { + Process.killProcess(Process.myPid()); + } + + /** + * Create and register a CrashHandler for all threads and thread groups. + */ + public CrashHandler() { + this((Context) null); + } + + /** + * Create and register a CrashHandler for all threads and thread groups. + * + * @param appContext A Context for retrieving application information. + */ + public CrashHandler(final Context appContext) { + this.appContext = appContext; + this.handlerThread = null; + this.systemUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler(); + Thread.setDefaultUncaughtExceptionHandler(this); + } + + /** + * Create and register a CrashHandler for a particular thread. + * + * @param thread A thread to register the CrashHandler + */ + public CrashHandler(final Thread thread) { + this(thread, null); + } + + /** + * Create and register a CrashHandler for a particular thread. + * + * @param thread A thread to register the CrashHandler + * @param appContext A Context for retrieving application information. + */ + public CrashHandler(final Thread thread, final Context appContext) { + this.appContext = appContext; + this.handlerThread = thread; + this.systemUncaughtHandler = thread.getUncaughtExceptionHandler(); + thread.setUncaughtExceptionHandler(this); + } + + /** + * Unregister this CrashHandler for exception handling. + */ + public void unregister() { + unregistered = true; + + // Restore the previous handler if we are still the topmost handler. + // If not, we are part of a chain of handlers, and we cannot just restore the previous + // handler, because that would replace whatever handler that's above us in the chain. + + if (handlerThread != null) { + if (handlerThread.getUncaughtExceptionHandler() == this) { + handlerThread.setUncaughtExceptionHandler(systemUncaughtHandler); + } + } else { + if (Thread.getDefaultUncaughtExceptionHandler() == this) { + Thread.setDefaultUncaughtExceptionHandler(systemUncaughtHandler); + } + } + } + + /** + * Record an exception stack in logs. + * + * @param thread The exception thread + * @param exc An exception + */ + public static void logException(final Thread thread, final Throwable exc) { + try { + Log.e(LOGTAG, ">>> REPORTING UNCAUGHT EXCEPTION FROM THREAD " + + thread.getId() + " (\"" + thread.getName() + "\")", exc); + + if (MAIN_THREAD != thread) { + Log.e(LOGTAG, "Main thread (" + MAIN_THREAD.getId() + ") stack:"); + for (StackTraceElement ste : MAIN_THREAD.getStackTrace()) { + Log.e(LOGTAG, " " + ste.toString()); + } + } + } catch (final Throwable e) { + // If something throws here, we want to continue to report the exception, + // so we catch all exceptions and ignore them. + } + } + + private static long getCrashTime() { + return System.currentTimeMillis() / 1000; + } + + private static long getStartupTime() { + // Process start time is also the proc file modified time. + final long uptimeMins = (new File("/proc/self/cmdline")).lastModified(); + if (uptimeMins == 0L) { + return getCrashTime(); + } + return uptimeMins / 1000; + } + + private static String getJavaPackageName() { + return CrashHandler.class.getPackage().getName(); + } + + private static String getProcessName() { + try { + final FileReader reader = new FileReader("/proc/self/cmdline"); + final char[] buffer = new char[64]; + try { + if (reader.read(buffer) > 0) { + // cmdline is delimited by '\0', and we want the first token. + final int nul = Arrays.asList(buffer).indexOf('\0'); + return (new String(buffer, 0, nul < 0 ? buffer.length : nul)).trim(); + } + } finally { + reader.close(); + } + } catch (final IOException e) { + } + + return null; + } + + protected String getAppPackageName() { + final Context context = getAppContext(); + + if (context != null) { + return context.getPackageName(); + } + + // Package name is also the process name in most cases. + String processName = getProcessName(); + if (processName != null) { + return processName; + } + + // Fallback to using CrashHandler's package name. + return getJavaPackageName(); + } + + protected Context getAppContext() { + return appContext; + } + + /** + * Get the crash "extras" to be reported. + * + * @param thread The exception thread + * @param exc An exception + * @return "Extras" in the from of a Bundle + */ + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Context context = getAppContext(); + final Bundle extras = new Bundle(); + final String pkgName = getAppPackageName(); + final String processName = getProcessName(); + + extras.putString("ProductName", pkgName); + extras.putLong("CrashTime", getCrashTime()); + extras.putLong("StartupTime", getStartupTime()); + extras.putString("AndroidProcessName", getProcessName()); + + if (context != null) { + final PackageManager pkgMgr = context.getPackageManager(); + try { + final PackageInfo pkgInfo = pkgMgr.getPackageInfo(pkgName, 0); + extras.putString("Version", pkgInfo.versionName); + extras.putInt("BuildID", pkgInfo.versionCode); + extras.putLong("InstallTime", pkgInfo.lastUpdateTime / 1000); + } catch (final PackageManager.NameNotFoundException e) { + Log.i(LOGTAG, "Error getting package info", e); + } + } + + extras.putString("JavaStackTrace", getExceptionStackTrace(exc)); + return extras; + } + + /** + * Get the crash minidump content to be reported. + * + * @param thread The exception thread + * @param exc An exception + * @return Minidump content + */ + protected byte[] getCrashDump(final Thread thread, final Throwable exc) { + return new byte[0]; // No minidump. + } + + protected static String normalizeUrlString(final String str) { + if (str == null) { + return ""; + } + return Uri.encode(str); + } + + /** + * Get the server URL to send the crash report to. + * + * @param extras The crash extras Bundle + */ + protected String getServerUrl(final Bundle extras) { + return String.format(DEFAULT_SERVER_URL, + normalizeUrlString(extras.getString("ProductID")), + normalizeUrlString(extras.getString("Version")), + normalizeUrlString(extras.getString("BuildID"))); + } + + /** + * Launch the crash reporter activity that sends the crash report to the server. + * + * @param dumpFile Path for the minidump file + * @param extraFile Path for the crash extra file + * @return Whether the crash reporter was successfully launched + */ + protected boolean launchCrashReporter(final String dumpFile, final String extraFile) { + try { + final Context context = getAppContext(); + final String javaPkg = getJavaPackageName(); + final String pkg = getAppPackageName(); + final String component = javaPkg + ".CrashReporter"; + final String action = javaPkg + ".reportCrash"; + final ProcessBuilder pb; + + if (context != null) { + final Intent intent = new Intent(action); + intent.setComponent(new ComponentName(pkg, component)); + intent.putExtra("minidumpPath", dumpFile); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + return true; + } + + // Avoid AppConstants dependency for SDK version constants, + // because CrashHandler could be used outside of Fennec code. + if (Build.VERSION.SDK_INT < 17) { + pb = new ProcessBuilder( + "/system/bin/am", "start", + "-a", action, + "-n", pkg + '/' + component, + "--es", "minidumpPath", dumpFile); + } else { + pb = new ProcessBuilder( + "/system/bin/am", "start", + "--user", /* USER_CURRENT_OR_SELF */ "-3", + "-a", action, + "-n", pkg + '/' + component, + "--es", "minidumpPath", dumpFile); + } + + pb.start().waitFor(); + + } catch (final IOException e) { + Log.e(LOGTAG, "Error launching crash reporter", e); + return false; + + } catch (final InterruptedException e) { + Log.i(LOGTAG, "Interrupted while waiting to launch crash reporter", e); + // Fall-through + } + return true; + } + + /** + * Report an exception to Socorro. + * + * @param thread The exception thread + * @param exc An exception + * @return Whether the exception was successfully reported + */ + protected boolean reportException(final Thread thread, final Throwable exc) { + final Context context = getAppContext(); + final String id = UUID.randomUUID().toString(); + + // Use the cache directory under the app directory to store crash files. + final File dir; + if (context != null) { + dir = context.getCacheDir(); + } else { + dir = new File("/data/data/" + getAppPackageName() + "/cache"); + } + + dir.mkdirs(); + if (!dir.exists()) { + return false; + } + + final File dmpFile = new File(dir, id + ".dmp"); + final File extraFile = new File(dir, id + ".extra"); + + try { + // Write out minidump file as binary. + + final byte[] minidump = getCrashDump(thread, exc); + final FileOutputStream dmpStream = new FileOutputStream(dmpFile); + try { + dmpStream.write(minidump); + } finally { + dmpStream.close(); + } + + } catch (final IOException e) { + Log.e(LOGTAG, "Error writing minidump file", e); + return false; + } + + try { + // Write out crash extra file as text. + + final Bundle extras = getCrashExtras(thread, exc); + final String url = getServerUrl(extras); + extras.putString("ServerURL", url); + + final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile)); + try { + for (String key : extras.keySet()) { + // Each extra line is in the format, key=value, with newlines escaped. + extraWriter.write(key); + extraWriter.write('='); + extraWriter.write(String.valueOf(extras.get(key)).replace("\n", "\\n")); + extraWriter.write('\n'); + } + } finally { + extraWriter.close(); + } + + } catch (final IOException e) { + Log.e(LOGTAG, "Error writing extra file", e); + return false; + } + + return launchCrashReporter(dmpFile.getAbsolutePath(), extraFile.getAbsolutePath()); + } + + /** + * Implements the default behavior for handling uncaught exceptions. + * + * @param thread The exception thread + * @param exc An uncaught exception + */ + @Override + public void uncaughtException(Thread thread, Throwable exc) { + if (this.crashing) { + // Prevent possible infinite recusions. + return; + } + + if (thread == null) { + // Gecko may pass in null for thread to denote the current thread. + thread = Thread.currentThread(); + } + + try { + if (!this.unregistered) { + // Only process crash ourselves if we have not been unregistered. + + this.crashing = true; + exc = getRootException(exc); + logException(thread, exc); + + if (reportException(thread, exc)) { + // Reporting succeeded; we can terminate our process now. + return; + } + } + + if (systemUncaughtHandler != null) { + // Follow the chain of uncaught handlers. + systemUncaughtHandler.uncaughtException(thread, exc); + } + } finally { + terminateProcess(); + } + } + + public static CrashHandler createDefaultCrashHandler(final Context context) { + return new CrashHandler(context) { + @Override + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Bundle extras = super.getCrashExtras(thread, exc); + + extras.putString("ProductName", AppConstants.MOZ_APP_BASENAME); + extras.putString("ProductID", AppConstants.MOZ_APP_ID); + extras.putString("Version", AppConstants.MOZ_APP_VERSION); + extras.putString("BuildID", AppConstants.MOZ_APP_BUILDID); + extras.putString("Vendor", AppConstants.MOZ_APP_VENDOR); + extras.putString("ReleaseChannel", AppConstants.MOZ_UPDATE_CHANNEL); + return extras; + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + if (AppConstants.MOZ_CRASHREPORTER && AppConstants.MOZILLA_OFFICIAL) { + // Only use Java crash reporter if enabled on official build. + return super.reportException(thread, exc); + } + return false; + } + }; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java new file mode 100644 index 000000000..6c4e67b43 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java @@ -0,0 +1,503 @@ +/* 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 org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSContainer; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +@RobocopTarget +public final class EventDispatcher { + private static final String LOGTAG = "GeckoEventDispatcher"; + /* package */ static final String GUID = "__guid__"; + private static final String STATUS_ERROR = "error"; + private static final String STATUS_SUCCESS = "success"; + + private static final EventDispatcher INSTANCE = new EventDispatcher(); + + /** + * The capacity of a HashMap is rounded up to the next power-of-2. Every time the size + * of the map goes beyond 75% of the capacity, the map is rehashed. Therefore, to + * empirically determine the initial capacity that avoids rehashing, we need to + * determine the initial size, divide it by 75%, and round up to the next power-of-2. + */ + private static final int DEFAULT_GECKO_NATIVE_EVENTS_COUNT = 0; // Default for HashMap + private static final int DEFAULT_GECKO_JSON_EVENTS_COUNT = 256; // Empirically measured + private static final int DEFAULT_UI_EVENTS_COUNT = 0; // Default for HashMap + private static final int DEFAULT_BACKGROUND_EVENTS_COUNT = 0; // Default for HashMap + + private final Map<String, List<NativeEventListener>> mGeckoThreadNativeListeners = + new HashMap<String, List<NativeEventListener>>(DEFAULT_GECKO_NATIVE_EVENTS_COUNT); + private final Map<String, List<GeckoEventListener>> mGeckoThreadJSONListeners = + new HashMap<String, List<GeckoEventListener>>(DEFAULT_GECKO_JSON_EVENTS_COUNT); + private final Map<String, List<BundleEventListener>> mUiThreadListeners = + new HashMap<String, List<BundleEventListener>>(DEFAULT_UI_EVENTS_COUNT); + private final Map<String, List<BundleEventListener>> mBackgroundThreadListeners = + new HashMap<String, List<BundleEventListener>>(DEFAULT_BACKGROUND_EVENTS_COUNT); + + @ReflectionTarget + public static EventDispatcher getInstance() { + return INSTANCE; + } + + public EventDispatcher() { + } + + private <T> void registerListener(final Class<?> listType, + final Map<String, List<T>> listenersMap, + final T listener, + final String[] events) { + try { + synchronized (listenersMap) { + for (final String event : events) { + List<T> listeners = listenersMap.get(event); + if (listeners == null) { + // Java doesn't let us put Class<? extends List<T>> as the type for listType. + @SuppressWarnings("unchecked") + final Class<? extends List<T>> type = (Class) listType; + listeners = type.newInstance(); + listenersMap.put(event, listeners); + } + if (!AppConstants.RELEASE_OR_BETA && listeners.contains(listener)) { + throw new IllegalStateException("Already registered " + event); + } + listeners.add(listener); + } + } + } catch (final IllegalAccessException | InstantiationException e) { + throw new IllegalArgumentException("Invalid new list type", e); + } + } + + private void checkNotRegisteredElsewhere(final Map<String, ?> allowedMap, + final String[] events) { + if (AppConstants.RELEASE_OR_BETA) { + // for performance reasons, we only check for + // already-registered listeners in non-release builds. + return; + } + for (final Map<String, ?> listenersMap : Arrays.asList(mGeckoThreadNativeListeners, + mGeckoThreadJSONListeners, + mUiThreadListeners, + mBackgroundThreadListeners)) { + if (listenersMap == allowedMap) { + continue; + } + synchronized (listenersMap) { + for (final String event : events) { + if (listenersMap.get(event) != null) { + throw new IllegalStateException( + "Already registered " + event + " under a different type"); + } + } + } + } + } + + private <T> void unregisterListener(final Map<String, List<T>> listenersMap, + final T listener, + final String[] events) { + synchronized (listenersMap) { + for (final String event : events) { + List<T> listeners = listenersMap.get(event); + if ((listeners == null || + !listeners.remove(listener)) && !AppConstants.RELEASE_OR_BETA) { + throw new IllegalArgumentException(event + " was not registered"); + } + } + } + } + + public void registerGeckoThreadListener(final NativeEventListener listener, + final String... events) { + checkNotRegisteredElsewhere(mGeckoThreadNativeListeners, events); + + // For listeners running on the Gecko thread, we want to notify the listeners + // outside of our synchronized block, because the listeners may take an + // indeterminate amount of time to run. Therefore, to ensure concurrency when + // iterating the list outside of the synchronized block, we use a + // CopyOnWriteArrayList. + registerListener(CopyOnWriteArrayList.class, + mGeckoThreadNativeListeners, listener, events); + } + + @Deprecated // Use NativeEventListener instead + public void registerGeckoThreadListener(final GeckoEventListener listener, + final String... events) { + checkNotRegisteredElsewhere(mGeckoThreadJSONListeners, events); + + registerListener(CopyOnWriteArrayList.class, + mGeckoThreadJSONListeners, listener, events); + } + + public void registerUiThreadListener(final BundleEventListener listener, + final String... events) { + checkNotRegisteredElsewhere(mUiThreadListeners, events); + + registerListener(ArrayList.class, + mUiThreadListeners, listener, events); + } + + @ReflectionTarget + public void registerBackgroundThreadListener(final BundleEventListener listener, + final String... events) { + checkNotRegisteredElsewhere(mBackgroundThreadListeners, events); + + registerListener(ArrayList.class, + mBackgroundThreadListeners, listener, events); + } + + public void unregisterGeckoThreadListener(final NativeEventListener listener, + final String... events) { + unregisterListener(mGeckoThreadNativeListeners, listener, events); + } + + @Deprecated // Use NativeEventListener instead + public void unregisterGeckoThreadListener(final GeckoEventListener listener, + final String... events) { + unregisterListener(mGeckoThreadJSONListeners, listener, events); + } + + public void unregisterUiThreadListener(final BundleEventListener listener, + final String... events) { + unregisterListener(mUiThreadListeners, listener, events); + } + + public void unregisterBackgroundThreadListener(final BundleEventListener listener, + final String... events) { + unregisterListener(mBackgroundThreadListeners, listener, events); + } + + private List<NativeEventListener> getNativeListeners(final String type) { + final List<NativeEventListener> listeners; + synchronized (mGeckoThreadNativeListeners) { + listeners = mGeckoThreadNativeListeners.get(type); + } + return listeners; + } + + private List<GeckoEventListener> getGeckoListeners(final String type) { + final List<GeckoEventListener> listeners; + synchronized (mGeckoThreadJSONListeners) { + listeners = mGeckoThreadJSONListeners.get(type); + } + return listeners; + } + + public boolean dispatchEvent(final NativeJSContainer message) { + // First try native listeners. + final String type = message.optString("type", null); + if (type == null) { + Log.e(LOGTAG, "JSON message must have a type property"); + return true; // It may seem odd to return true here, but it's necessary to preserve the correct behavior. + } + + final List<NativeEventListener> listeners = getNativeListeners(type); + + final String guid = message.optString(GUID, null); + EventCallback callback = null; + if (guid != null) { + callback = new GeckoEventCallback(guid, type); + } + + if (listeners != null) { + if (listeners.isEmpty()) { + Log.w(LOGTAG, "No listeners for " + type); + + // There were native listeners, and they're gone. Return a failure rather than + // looking for JSON listeners. This is an optimization, as we can safely assume + // that an event which previously had native listeners will never have JSON + // listeners. + return false; + } + try { + for (final NativeEventListener listener : listeners) { + listener.handleMessage(type, message, callback); + } + } catch (final NativeJSObject.InvalidPropertyException e) { + Log.e(LOGTAG, "Exception occurred while handling " + type, e); + } + // If we found native listeners, we assume we don't have any other types of listeners + // and return early. This assumption is checked when registering listeners. + return true; + } + + // Check for thread event listeners before checking for JSON event listeners, + // because checking for thread listeners is very fast and doesn't require us to + // serialize into JSON and construct a JSONObject. + if (dispatchToThreads(type, message, /* bundle */ null, callback)) { + // If we found thread listeners, we assume we don't have any other types of listeners + // and return early. This assumption is checked when registering listeners. + return true; + } + + try { + // If we didn't find native listeners, try JSON listeners. + return dispatchEvent(new JSONObject(message.toString()), callback); + } catch (final JSONException e) { + Log.e(LOGTAG, "Cannot parse JSON", e); + } catch (final UnsupportedOperationException e) { + Log.e(LOGTAG, "Cannot convert message to JSON", e); + } + + return true; + } + + /** + * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners). + * + * @param message Bundle message with "type" value specifying the event type. + */ + public void dispatch(final Bundle message) { + dispatch(message, /* callback */ null); + } + + /** + * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners). + * + * @param message Bundle message with "type" value specifying the event type. + * @param callback Optional object for callbacks from events. + */ + public void dispatch(final Bundle message, final EventCallback callback) { + if (message == null) { + throw new IllegalArgumentException("Null message"); + } + + final String type = message.getCharSequence("type").toString(); + if (type == null) { + Log.e(LOGTAG, "Bundle message must have a type property"); + return; + } + dispatchToThreads(type, /* js */ null, message, /* callback */ callback); + } + + /** + * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners). + * + * @param type Event type + * @param message Bundle message + */ + public void dispatch(final String type, final Bundle message) { + dispatch(type, message, /* callback */ null); + } + + /** + * Dispatch event to any registered Bundle listeners (non-Gecko thread listeners). + * + * @param type Event type + * @param message Bundle message + * @param callback Optional object for callbacks from events. + */ + public void dispatch(final String type, final Bundle message, final EventCallback callback) { + dispatchToThreads(type, /* js */ null, message, /* callback */ callback); + } + + private boolean dispatchToThreads(final String type, + final NativeJSObject jsMessage, + final Bundle bundleMessage, + final EventCallback callback) { + if (dispatchToThread(type, jsMessage, bundleMessage, callback, + mUiThreadListeners, ThreadUtils.getUiHandler())) { + return true; + } + + if (dispatchToThread(type, jsMessage, bundleMessage, callback, + mBackgroundThreadListeners, ThreadUtils.getBackgroundHandler())) { + return true; + } + + if (jsMessage == null) { + Log.w(LOGTAG, "No listeners for " + type + " in dispatchToThreads"); + } + + if (!AppConstants.RELEASE_OR_BETA && jsMessage == null) { + // We're dispatching a Bundle message. Because Gecko thread listeners are not + // supported for Bundle messages, do a sanity check to make sure we don't have + // matching Gecko thread listeners. + boolean hasGeckoListener = false; + synchronized (mGeckoThreadNativeListeners) { + hasGeckoListener |= mGeckoThreadNativeListeners.containsKey(type); + } + synchronized (mGeckoThreadJSONListeners) { + hasGeckoListener |= mGeckoThreadJSONListeners.containsKey(type); + } + if (hasGeckoListener) { + throw new IllegalStateException( + "Dispatching Bundle message to Gecko listener " + type); + } + } + + return false; + } + + private boolean dispatchToThread(final String type, + final NativeJSObject jsMessage, + final Bundle bundleMessage, + final EventCallback callback, + final Map<String, List<BundleEventListener>> listenersMap, + final Handler thread) { + // We need to hold the lock throughout dispatching, to ensure the listeners list + // is consistent, while we iterate over it. We don't have to worry about listeners + // running for a long time while we have the lock, because the listeners will run + // on a separate thread. + synchronized (listenersMap) { + final List<BundleEventListener> listeners = listenersMap.get(type); + if (listeners == null) { + return false; + } + + if (listeners.isEmpty()) { + Log.w(LOGTAG, "No listeners for " + type + " in dispatchToThread"); + + // There were native listeners, and they're gone. + return false; + } + + final Bundle messageAsBundle; + try { + messageAsBundle = jsMessage != null ? jsMessage.toBundle() : bundleMessage; + } catch (final NativeJSObject.InvalidPropertyException e) { + Log.e(LOGTAG, "Exception occurred while handling " + type, e); + return true; + } + + // Event listeners will call | callback.sendError | if applicable. + for (final BundleEventListener listener : listeners) { + thread.post(new Runnable() { + @Override + public void run() { + listener.handleMessage(type, messageAsBundle, callback); + } + }); + } + return true; + } + } + + public boolean dispatchEvent(final JSONObject message, final EventCallback callback) { + // { + // "type": "value", + // "event_specific": "value", + // ... + try { + final String type = message.getString("type"); + + final List<GeckoEventListener> listeners = getGeckoListeners(type); + + if (listeners == null || listeners.isEmpty()) { + Log.w(LOGTAG, "No listeners for " + type + " in dispatchEvent"); + + return false; + } + + for (final GeckoEventListener listener : listeners) { + listener.handleMessage(type, message); + } + } catch (final JSONException e) { + Log.e(LOGTAG, "handleGeckoMessage throws " + e, e); + } + + return true; + } + + @RobocopTarget + @Deprecated + public static void sendResponse(JSONObject message, Object response) { + sendResponseHelper(STATUS_SUCCESS, message, response); + } + + @Deprecated + public static void sendError(JSONObject message, Object response) { + sendResponseHelper(STATUS_ERROR, message, response); + } + + @Deprecated + private static void sendResponseHelper(String status, JSONObject message, Object response) { + try { + final String topic = message.getString("type") + ":Response"; + final JSONObject wrapper = new JSONObject(); + wrapper.put(GUID, message.getString(GUID)); + wrapper.put("status", status); + wrapper.put("response", response); + + if (ThreadUtils.isOnGeckoThread()) { + GeckoAppShell.syncNotifyObservers(topic, wrapper.toString()); + } else { + GeckoAppShell.notifyObservers(topic, wrapper.toString(), + GeckoThread.State.PROFILE_READY); + } + } catch (final JSONException e) { + Log.e(LOGTAG, "Unable to send response", e); + } + } + + /* package */ static class GeckoEventCallback implements EventCallback { + private final String guid; + private final String type; + private boolean sent; + + public GeckoEventCallback(final String guid, final String type) { + this.guid = guid; + this.type = type; + } + + @Override + public void sendSuccess(final Object response) { + sendResponse(STATUS_SUCCESS, response); + } + + @Override + public void sendError(final Object response) { + sendResponse(STATUS_ERROR, response); + } + + private void sendResponse(final String status, final Object response) { + if (sent) { + throw new IllegalStateException("Callback has already been executed for type=" + + type + ", guid=" + guid); + } + + sent = true; + + try { + final String topic = type + ":Response"; + final JSONObject wrapper = new JSONObject(); + wrapper.put(GUID, guid); + wrapper.put("status", status); + wrapper.put("response", response); + + if (ThreadUtils.isOnGeckoThread()) { + GeckoAppShell.syncNotifyObservers(topic, wrapper.toString()); + } else { + GeckoAppShell.notifyObservers(topic, wrapper.toString(), + GeckoThread.State.PROFILE_READY); + } + } catch (final JSONException e) { + Log.e(LOGTAG, "Unable to send response for: " + type, e); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.java new file mode 100644 index 000000000..8d4c0fb2a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAccessibility.java @@ -0,0 +1,410 @@ +/* -*- 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 org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.UIAsyncTask; + +import android.content.Context; +import android.graphics.Rect; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.view.ViewParent; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeProvider; + +import com.googlecode.eyesfree.braille.selfbraille.SelfBrailleClient; +import com.googlecode.eyesfree.braille.selfbraille.WriteData; + +public class GeckoAccessibility { + private static final String LOGTAG = "GeckoAccessibility"; + private static final int VIRTUAL_ENTRY_POINT_BEFORE = 1; + private static final int VIRTUAL_CURSOR_POSITION = 2; + private static final int VIRTUAL_ENTRY_POINT_AFTER = 3; + + private static boolean sEnabled; + // Used to store the JSON message and populate the event later in the code path. + private static JSONObject sHoverEnter; + private static AccessibilityNodeInfo sVirtualCursorNode; + private static int sCurrentNode; + + // This is the number Brailleback uses to start indexing routing keys. + private static final int BRAILLE_CLICK_BASE_INDEX = -275000000; + private static SelfBrailleClient sSelfBrailleClient; + + public static void updateAccessibilitySettings (final Context context) { + new UIAsyncTask.WithoutParams<Void>(ThreadUtils.getBackgroundHandler()) { + @Override + public Void doInBackground() { + JSONObject ret = new JSONObject(); + sEnabled = false; + AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + sEnabled = accessibilityManager.isEnabled() && accessibilityManager.isTouchExplorationEnabled(); + if (Versions.feature16Plus && sEnabled && sSelfBrailleClient == null) { + sSelfBrailleClient = new SelfBrailleClient(context, false); + } + + try { + ret.put("enabled", sEnabled); + } catch (Exception ex) { + Log.e(LOGTAG, "Error building JSON arguments for Accessibility:Settings:", ex); + } + + GeckoAppShell.notifyObservers("Accessibility:Settings", ret.toString()); + return null; + } + + @Override + public void onPostExecute(Void args) { + final GeckoAppShell.GeckoInterface geckoInterface = GeckoAppShell.getGeckoInterface(); + if (geckoInterface == null) { + return; + } + geckoInterface.setAccessibilityEnabled(sEnabled); + } + }.execute(); + } + + private static void populateEventFromJSON (AccessibilityEvent event, JSONObject message) { + final JSONArray textArray = message.optJSONArray("text"); + if (textArray != null) { + for (int i = 0; i < textArray.length(); i++) + event.getText().add(textArray.optString(i)); + } + + event.setContentDescription(message.optString("description")); + event.setEnabled(message.optBoolean("enabled", true)); + event.setChecked(message.optBoolean("checked")); + event.setPassword(message.optBoolean("password")); + event.setAddedCount(message.optInt("addedCount", -1)); + event.setRemovedCount(message.optInt("removedCount", -1)); + event.setFromIndex(message.optInt("fromIndex", -1)); + event.setItemCount(message.optInt("itemCount", -1)); + event.setCurrentItemIndex(message.optInt("currentItemIndex", -1)); + event.setBeforeText(message.optString("beforeText")); + event.setToIndex(message.optInt("toIndex", -1)); + event.setScrollable(message.optBoolean("scrollable")); + event.setScrollX(message.optInt("scrollX", -1)); + event.setScrollY(message.optInt("scrollY", -1)); + event.setMaxScrollX(message.optInt("maxScrollX", -1)); + event.setMaxScrollY(message.optInt("maxScrollY", -1)); + } + + private static void sendDirectAccessibilityEvent(int eventType, JSONObject message) { + final Context context = GeckoAppShell.getApplicationContext(); + final AccessibilityEvent accEvent = AccessibilityEvent.obtain(eventType); + accEvent.setClassName(GeckoAccessibility.class.getName()); + accEvent.setPackageName(context.getPackageName()); + populateEventFromJSON(accEvent, message); + AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + try { + accessibilityManager.sendAccessibilityEvent(accEvent); + } catch (IllegalStateException e) { + // Accessibility is off. + } + } + + public static boolean isEnabled() { + return sEnabled; + } + + public static void sendAccessibilityEvent (final JSONObject message) { + if (!sEnabled) + return; + + final int eventType = message.optInt("eventType", -1); + if (eventType < 0) { + Log.e(LOGTAG, "No accessibility event type provided"); + return; + } + + sendAccessibilityEvent(message, eventType); + } + + public static void sendAccessibilityEvent (final JSONObject message, final int eventType) { + if (!sEnabled) + return; + + final String exitView = message.optString("exitView"); + if (exitView.equals("moveNext")) { + sCurrentNode = VIRTUAL_ENTRY_POINT_AFTER; + } else if (exitView.equals("movePrevious")) { + sCurrentNode = VIRTUAL_ENTRY_POINT_BEFORE; + } else { + sCurrentNode = VIRTUAL_CURSOR_POSITION; + } + + if (Versions.preJB) { + // Before Jelly Bean we send events directly from here while spoofing the source by setting + // the package and class name manually. + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + sendDirectAccessibilityEvent(eventType, message); + } + }); + } else { + // In Jelly Bean we populate an AccessibilityNodeInfo with the minimal amount of data to have + // it work with TalkBack. + final View view = GeckoAppShell.getLayerView(); + if (view == null) + return; + + if (sVirtualCursorNode == null) + sVirtualCursorNode = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); + sVirtualCursorNode.setEnabled(message.optBoolean("enabled", true)); + sVirtualCursorNode.setClickable(message.optBoolean("clickable")); + sVirtualCursorNode.setCheckable(message.optBoolean("checkable")); + sVirtualCursorNode.setChecked(message.optBoolean("checked")); + sVirtualCursorNode.setPassword(message.optBoolean("password")); + + final JSONArray textArray = message.optJSONArray("text"); + StringBuilder sb = new StringBuilder(); + if (textArray != null && textArray.length() > 0) { + sb.append(textArray.optString(0)); + for (int i = 1; i < textArray.length(); i++) { + sb.append(" ").append(textArray.optString(i)); + } + sVirtualCursorNode.setText(sb.toString()); + } + sVirtualCursorNode.setContentDescription(message.optString("description")); + + JSONObject bounds = message.optJSONObject("bounds"); + if (bounds != null) { + Rect relativeBounds = new Rect(bounds.optInt("left"), bounds.optInt("top"), + bounds.optInt("right"), bounds.optInt("bottom")); + sVirtualCursorNode.setBoundsInParent(relativeBounds); + int[] locationOnScreen = new int[2]; + view.getLocationOnScreen(locationOnScreen); + Rect screenBounds = new Rect(relativeBounds); + screenBounds.offset(locationOnScreen[0], locationOnScreen[1]); + sVirtualCursorNode.setBoundsInScreen(screenBounds); + } + + final JSONObject braille = message.optJSONObject("brailleOutput"); + if (braille != null) { + sendBrailleText(view, braille.optString("text"), + braille.optInt("selectionStart"), braille.optInt("selectionEnd")); + } + + if (eventType == AccessibilityEvent.TYPE_VIEW_HOVER_ENTER) { + sHoverEnter = message; + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + event.setClassName(GeckoAccessibility.class.getName()); + if (eventType == AccessibilityEvent.TYPE_ANNOUNCEMENT || + eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) { + event.setSource(view, View.NO_ID); + } else { + event.setSource(view, VIRTUAL_CURSOR_POSITION); + } + populateEventFromJSON(event, message); + ((ViewParent) view).requestSendAccessibilityEvent(view, event); + } + }); + + } + } + + private static void sendBrailleText(final View view, final String text, final int selectionStart, final int selectionEnd) { + AccessibilityNodeInfo info = AccessibilityNodeInfo.obtain(view, VIRTUAL_CURSOR_POSITION); + WriteData data = WriteData.forInfo(info); + data.setText(text); + // Set either the focus blink or the current caret position/selection + data.setSelectionStart(selectionStart); + data.setSelectionEnd(selectionEnd); + sSelfBrailleClient.write(data); + } + + public static void setDelegate(View view) { + // Only use this delegate in Jelly Bean. + if (Versions.feature16Plus) { + view.setAccessibilityDelegate(new GeckoAccessibilityDelegate()); + view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } + } + + public static void setAccessibilityManagerListeners(final Context context) { + AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + + accessibilityManager.addAccessibilityStateChangeListener(new AccessibilityManager.AccessibilityStateChangeListener() { + @Override + public void onAccessibilityStateChanged(boolean enabled) { + updateAccessibilitySettings(context); + } + }); + + if (Versions.feature19Plus) { + accessibilityManager.addTouchExplorationStateChangeListener(new AccessibilityManager.TouchExplorationStateChangeListener() { + @Override + public void onTouchExplorationStateChanged(boolean enabled) { + updateAccessibilitySettings(context); + } + }); + } + } + + public static void onLayerViewFocusChanged(boolean gainFocus) { + if (sEnabled) + GeckoAppShell.notifyObservers("Accessibility:Focus", gainFocus ? "true" : "false"); + } + + public static class GeckoAccessibilityDelegate extends View.AccessibilityDelegate { + AccessibilityNodeProvider mAccessibilityNodeProvider; + + @Override + public AccessibilityNodeProvider getAccessibilityNodeProvider(final View host) { + if (mAccessibilityNodeProvider == null) + // The accessibility node structure for web content consists of 3 LayerView child nodes: + // 1. VIRTUAL_ENTRY_POINT_BEFORE: Represents the entry point before the LayerView. + // 2. VIRTUAL_CURSOR_POSITION: Represents the current position of the virtual cursor. + // 3. VIRTUAL_ENTRY_POINT_AFTER: Represents the entry point after the LayerView. + mAccessibilityNodeProvider = new AccessibilityNodeProvider() { + @Override + public AccessibilityNodeInfo createAccessibilityNodeInfo(int virtualDescendantId) { + AccessibilityNodeInfo info = (virtualDescendantId == VIRTUAL_CURSOR_POSITION && sVirtualCursorNode != null) ? + AccessibilityNodeInfo.obtain(sVirtualCursorNode) : + AccessibilityNodeInfo.obtain(host, virtualDescendantId); + + switch (virtualDescendantId) { + case View.NO_ID: + // This is the parent LayerView node, populate it with children. + onInitializeAccessibilityNodeInfo(host, info); + info.addChild(host, VIRTUAL_ENTRY_POINT_BEFORE); + info.addChild(host, VIRTUAL_CURSOR_POSITION); + info.addChild(host, VIRTUAL_ENTRY_POINT_AFTER); + break; + default: + info.setParent(host); + info.setSource(host, virtualDescendantId); + info.setVisibleToUser(host.isShown()); + info.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + info.setClassName(host.getClass().getName()); + info.setEnabled(true); + info.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); + info.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); + info.addAction(AccessibilityNodeInfo.ACTION_CLICK); + info.addAction(AccessibilityNodeInfo.ACTION_LONG_CLICK); + info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); + info.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + info.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + info.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); + info.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); + info.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER | + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD | + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE | + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); + break; + } + return info; + } + + @Override + public boolean performAction (int virtualViewId, int action, Bundle arguments) { + if (action == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS) { + // The accessibility focus is permanently on the middle node, VIRTUAL_CURSOR_POSITION. + // When we enter the view forward or backward we just ask Gecko to get focus, keeping the current position. + if (virtualViewId == VIRTUAL_CURSOR_POSITION && sHoverEnter != null) { + GeckoAccessibility.sendAccessibilityEvent(sHoverEnter, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); + } else { + GeckoAppShell.notifyObservers("Accessibility:Focus", "true"); + } + return true; + } else if (action == AccessibilityNodeInfo.ACTION_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { + GeckoAppShell.notifyObservers("Accessibility:ActivateObject", null); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_LONG_CLICK && virtualViewId == VIRTUAL_CURSOR_POSITION) { + GeckoAppShell.notifyObservers("Accessibility:LongPress", null); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_FORWARD && virtualViewId == VIRTUAL_CURSOR_POSITION) { + GeckoAppShell.notifyObservers("Accessibility:ScrollForward", null); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD && virtualViewId == VIRTUAL_CURSOR_POSITION) { + GeckoAppShell.notifyObservers("Accessibility:ScrollBackward", null); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT && virtualViewId == VIRTUAL_CURSOR_POSITION) { + String traversalRule = ""; + if (arguments != null) { + traversalRule = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING); + } + GeckoAppShell.notifyObservers("Accessibility:NextObject", traversalRule); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT && virtualViewId == VIRTUAL_CURSOR_POSITION) { + String traversalRule = ""; + if (arguments != null) { + traversalRule = arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING); + } + GeckoAppShell.notifyObservers("Accessibility:PreviousObject", traversalRule); + return true; + } else if (action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY && + virtualViewId == VIRTUAL_CURSOR_POSITION) { + // XXX: Self brailling gives this action with a bogus argument instead of an actual click action; + // the argument value is the BRAILLE_CLICK_BASE_INDEX - the index of the routing key that was hit. + // Other negative values are used by ChromeVox, but we don't support them. + // FAKE_GRANULARITY_READ_CURRENT = -1 + // FAKE_GRANULARITY_READ_TITLE = -2 + // FAKE_GRANULARITY_STOP_SPEECH = -3 + // FAKE_GRANULARITY_CHANGE_SHIFTER = -4 + int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + if (granularity <= BRAILLE_CLICK_BASE_INDEX) { + int keyIndex = BRAILLE_CLICK_BASE_INDEX - granularity; + JSONObject activationData = new JSONObject(); + try { + activationData.put("keyIndex", keyIndex); + } catch (JSONException e) { + return true; + } + GeckoAppShell.notifyObservers("Accessibility:ActivateObject", activationData.toString()); + } else if (granularity > 0) { + JSONObject movementData = new JSONObject(); + try { + movementData.put("direction", "Next"); + movementData.put("granularity", granularity); + } catch (JSONException e) { + return true; + } + GeckoAppShell.notifyObservers("Accessibility:MoveByGranularity", movementData.toString()); + } + return true; + } else if (action == AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY && + virtualViewId == VIRTUAL_CURSOR_POSITION) { + JSONObject movementData = new JSONObject(); + int granularity = arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + try { + movementData.put("direction", "Previous"); + movementData.put("granularity", granularity); + } catch (JSONException e) { + return true; + } + if (granularity > 0) { + GeckoAppShell.notifyObservers("Accessibility:MoveByGranularity", movementData.toString()); + } + return true; + } + return host.performAccessibilityAction(action, arguments); + } + }; + + return mAccessibilityNodeProvider; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java new file mode 100644 index 000000000..a80212639 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java @@ -0,0 +1,2239 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 java.io.BufferedReader; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.net.MalformedURLException; +import java.net.Proxy; +import java.net.URLConnection; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.StringTokenizer; +import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; + +import android.annotation.SuppressLint; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.gfx.BitmapUtils; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.gfx.PanZoomController; +import org.mozilla.gecko.permissions.Permissions; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoRequest; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSContainer; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ProxySelector; +import org.mozilla.gecko.util.ThreadUtils; + +import android.Manifest; +import android.app.Activity; +import android.app.ActivityManager; +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.PackageManager.NameNotFoundException; +import android.content.pm.ResolveInfo; +import android.content.pm.ServiceInfo; +import android.content.pm.Signature; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.ImageFormat; +import android.graphics.PixelFormat; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.SurfaceTexture; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.hardware.Camera; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Looper; +import android.os.SystemClock; +import android.os.Vibrator; +import android.provider.Settings; +import android.telephony.TelephonyManager; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.Display; +import android.view.HapticFeedbackConstants; +import android.view.Surface; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.WindowManager; +import android.view.inputmethod.InputMethodManager; +import android.webkit.MimeTypeMap; +import android.widget.AbsoluteLayout; + +public class GeckoAppShell +{ + private static final String LOGTAG = "GeckoAppShell"; + + // We have static members only. + private GeckoAppShell() { } + + private static final CrashHandler CRASH_HANDLER = new CrashHandler() { + @Override + protected String getAppPackageName() { + return AppConstants.ANDROID_PACKAGE_NAME; + } + + @Override + protected Context getAppContext() { + return sContextGetter != null ? getApplicationContext() : null; + } + + @Override + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Bundle extras = super.getCrashExtras(thread, exc); + + extras.putString("ProductName", AppConstants.MOZ_APP_BASENAME); + extras.putString("ProductID", AppConstants.MOZ_APP_ID); + extras.putString("Version", AppConstants.MOZ_APP_VERSION); + extras.putString("BuildID", AppConstants.MOZ_APP_BUILDID); + extras.putString("Vendor", AppConstants.MOZ_APP_VENDOR); + extras.putString("ReleaseChannel", AppConstants.MOZ_UPDATE_CHANNEL); + return extras; + } + + @Override + public void uncaughtException(final Thread thread, final Throwable exc) { + if (GeckoThread.isState(GeckoThread.State.EXITING) || + GeckoThread.isState(GeckoThread.State.EXITED)) { + // We've called System.exit. All exceptions after this point are Android + // berating us for being nasty to it. + return; + } + + super.uncaughtException(thread, exc); + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + try { + if (exc instanceof OutOfMemoryError) { + SharedPreferences prefs = getSharedPreferences(); + SharedPreferences.Editor editor = prefs.edit(); + editor.putBoolean(PREFS_OOM_EXCEPTION, true); + + // Synchronously write to disk so we know it's done before we + // shutdown + editor.commit(); + } + + reportJavaCrash(exc, getExceptionStackTrace(exc)); + + } catch (final Throwable e) { + } + + // reportJavaCrash should have caused us to hard crash. If we're still here, + // it probably means Gecko is not loaded, and we should do something else. + if (AppConstants.MOZ_CRASHREPORTER && AppConstants.MOZILLA_OFFICIAL) { + // Only use Java crash reporter if enabled on official build. + return super.reportException(thread, exc); + } + return false; + } + }; + + public static CrashHandler ensureCrashHandling() { + // Crash handling is automatically enabled when GeckoAppShell is loaded. + return CRASH_HANDLER; + } + + private static volatile boolean locationHighAccuracyEnabled; + + // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB. + private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768; + + static private int sDensityDpi; + static private int sScreenDepth; + + /* Is the value in sVibrationEndTime valid? */ + private static boolean sVibrationMaybePlaying; + + /* Time (in System.nanoTime() units) when the currently-playing vibration + * is scheduled to end. This value is valid only when + * sVibrationMaybePlaying is true. */ + private static long sVibrationEndTime; + + private static Sensor gAccelerometerSensor; + private static Sensor gLinearAccelerometerSensor; + private static Sensor gGyroscopeSensor; + private static Sensor gOrientationSensor; + private static Sensor gProximitySensor; + private static Sensor gLightSensor; + private static Sensor gRotationVectorSensor; + private static Sensor gGameRotationVectorSensor; + + private static final String GECKOREQUEST_RESPONSE_KEY = "response"; + private static final String GECKOREQUEST_ERROR_KEY = "error"; + + /* + * Keep in sync with constants found here: + * http://dxr.mozilla.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl + */ + static public final int WPL_STATE_START = 0x00000001; + static public final int WPL_STATE_STOP = 0x00000010; + static public final int WPL_STATE_IS_DOCUMENT = 0x00020000; + static public final int WPL_STATE_IS_NETWORK = 0x00040000; + + /* Keep in sync with constants found here: + http://dxr.mozilla.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + static public final int LINK_TYPE_UNKNOWN = 0; + static public final int LINK_TYPE_ETHERNET = 1; + static public final int LINK_TYPE_USB = 2; + static public final int LINK_TYPE_WIFI = 3; + static public final int LINK_TYPE_WIMAX = 4; + static public final int LINK_TYPE_2G = 5; + static public final int LINK_TYPE_3G = 6; + static public final int LINK_TYPE_4G = 7; + + public static final String PREFS_OOM_EXCEPTION = "OOMException"; + + /* The Android-side API: API methods that Android calls */ + + // helper methods + @WrapForJNI + /* package */ static native void reportJavaCrash(Throwable exc, String stackTrace); + + @WrapForJNI(dispatchTo = "gecko") + public static native void notifyUriVisited(String uri); + + private static LayerView sLayerView; + private static Rect sScreenSize; + + public static void setLayerView(LayerView lv) { + if (sLayerView == lv) { + return; + } + sLayerView = lv; + } + + @RobocopTarget + public static LayerView getLayerView() { + return sLayerView; + } + + /** + * Sends an asynchronous request to Gecko. + * + * The response data will be passed to {@link GeckoRequest#onResponse(NativeJSObject)} if the + * request succeeds; otherwise, {@link GeckoRequest#onError()} will fire. + * + * It can be called from any thread. The GeckoRequest callbacks will be executed on the Gecko thread. + * + * @param request The request to dispatch. Cannot be null. + */ + @RobocopTarget + public static void sendRequestToGecko(final GeckoRequest request) { + final String responseMessage = "Gecko:Request" + request.getId(); + + EventDispatcher.getInstance().registerGeckoThreadListener(new NativeEventListener() { + @Override + public void handleMessage(String event, NativeJSObject message, EventCallback callback) { + EventDispatcher.getInstance().unregisterGeckoThreadListener(this, event); + if (!message.has(GECKOREQUEST_RESPONSE_KEY)) { + request.onError(message.getObject(GECKOREQUEST_ERROR_KEY)); + return; + } + request.onResponse(message.getObject(GECKOREQUEST_RESPONSE_KEY)); + } + }, responseMessage); + + notifyObservers(request.getName(), request.getData()); + } + + // Synchronously notify a Gecko observer; must be called from Gecko thread. + @WrapForJNI(calledFrom = "gecko") + public static native void syncNotifyObservers(String topic, String data); + + @WrapForJNI(stubName = "NotifyObservers", dispatchTo = "gecko") + private static native void nativeNotifyObservers(String topic, String data); + + @RobocopTarget + public static void notifyObservers(final String topic, final String data) { + notifyObservers(topic, data, GeckoThread.State.RUNNING); + } + + public static void notifyObservers(final String topic, final String data, final GeckoThread.State state) { + if (GeckoThread.isStateAtLeast(state)) { + nativeNotifyObservers(topic, data); + } else { + GeckoThread.queueNativeCallUntil( + state, GeckoAppShell.class, "nativeNotifyObservers", + String.class, topic, String.class, data); + } + } + + /* + * The Gecko-side API: API methods that Gecko calls + */ + + @WrapForJNI(exceptionMode = "ignore") + private static String getExceptionStackTrace(Throwable e) { + return CrashHandler.getExceptionStackTrace(CrashHandler.getRootException(e)); + } + + @WrapForJNI(exceptionMode = "ignore") + private static void handleUncaughtException(Throwable e) { + CRASH_HANDLER.uncaughtException(null, e); + } + + @WrapForJNI + public static void openWindowForNotification() { + Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME, AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS); + + getApplicationContext().startActivity(intent); + } + + private static float getLocationAccuracy(Location location) { + float radius = location.getAccuracy(); + return (location.hasAccuracy() && radius > 0) ? radius : 1001; + } + + @SuppressLint("MissingPermission") // Permissions are explicitly checked for in enableLocation() + private static Location getLastKnownLocation(LocationManager lm) { + Location lastKnownLocation = null; + List<String> providers = lm.getAllProviders(); + + for (String provider : providers) { + Location location = lm.getLastKnownLocation(provider); + if (location == null) { + continue; + } + + if (lastKnownLocation == null) { + lastKnownLocation = location; + continue; + } + + long timeDiff = location.getTime() - lastKnownLocation.getTime(); + if (timeDiff > 0 || + (timeDiff == 0 && + getLocationAccuracy(location) < getLocationAccuracy(lastKnownLocation))) { + lastKnownLocation = location; + } + } + + return lastKnownLocation; + } + + @WrapForJNI(calledFrom = "gecko") + @SuppressLint("MissingPermission") // Permissions are explicitly checked for within this method + private static void enableLocation(final boolean enable) { + final Runnable requestLocation = new Runnable() { + @Override + public void run() { + LocationManager lm = getLocationManager(getApplicationContext()); + if (lm == null) { + return; + } + + if (!enable) { + lm.removeUpdates(getLocationListener()); + return; + } + + Location lastKnownLocation = getLastKnownLocation(lm); + if (lastKnownLocation != null) { + getLocationListener().onLocationChanged(lastKnownLocation); + } + + Criteria criteria = new Criteria(); + criteria.setSpeedRequired(false); + criteria.setBearingRequired(false); + criteria.setAltitudeRequired(false); + if (locationHighAccuracyEnabled) { + criteria.setAccuracy(Criteria.ACCURACY_FINE); + criteria.setCostAllowed(true); + criteria.setPowerRequirement(Criteria.POWER_HIGH); + } else { + criteria.setAccuracy(Criteria.ACCURACY_COARSE); + criteria.setCostAllowed(false); + criteria.setPowerRequirement(Criteria.POWER_LOW); + } + + String provider = lm.getBestProvider(criteria, true); + if (provider == null) + return; + + Looper l = Looper.getMainLooper(); + lm.requestLocationUpdates(provider, 100, 0.5f, getLocationListener(), l); + } + }; + + Permissions + .from((Activity) getContext()) + .withPermissions(Manifest.permission.ACCESS_FINE_LOCATION) + .onUIThread() + .doNotPromptIf(!enable) + .run(requestLocation); + } + + private static LocationManager getLocationManager(Context context) { + try { + return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + } catch (NoSuchFieldError e) { + // Some Tegras throw exceptions about missing the CONTROL_LOCATION_UPDATES permission, + // which allows enabling/disabling location update notifications from the cell radio. + // CONTROL_LOCATION_UPDATES is not for use by normal applications, but we might be + // hitting this problem if the Tegras are confused about missing cell radios. + Log.e(LOGTAG, "LOCATION_SERVICE not found?!", e); + return null; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableLocationHighAccuracy(final boolean enable) { + locationHighAccuracyEnabled = enable; + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean setAlarm(int aSeconds, int aNanoSeconds) { + AlarmManager am = (AlarmManager) + getApplicationContext().getSystemService(Context.ALARM_SERVICE); + + Intent intent = new Intent(getApplicationContext(), AlarmReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast( + getApplicationContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + // AlarmManager only supports millisecond precision + long time = ((long) aSeconds * 1000) + ((long) aNanoSeconds / 1_000_000L); + am.setExact(AlarmManager.RTC_WAKEUP, time, pi); + + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableAlarm() { + AlarmManager am = (AlarmManager) + getApplicationContext().getSystemService(Context.ALARM_SERVICE); + + Intent intent = new Intent(getApplicationContext(), AlarmReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast( + getApplicationContext(), 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + am.cancel(pi); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + /* package */ static native void onSensorChanged(int hal_type, float x, float y, float z, + float w, int accuracy, long time); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + /* package */ static native void onLocationChanged(double latitude, double longitude, + double altitude, float accuracy, + float bearing, float speed, long time); + + private static class DefaultListeners + implements SensorEventListener, LocationListener, NotificationListener { + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } + + private static int HalSensorAccuracyFor(int androidAccuracy) { + switch (androidAccuracy) { + case SensorManager.SENSOR_STATUS_UNRELIABLE: + return GeckoHalDefines.SENSOR_ACCURACY_UNRELIABLE; + case SensorManager.SENSOR_STATUS_ACCURACY_LOW: + return GeckoHalDefines.SENSOR_ACCURACY_LOW; + case SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM: + return GeckoHalDefines.SENSOR_ACCURACY_MED; + case SensorManager.SENSOR_STATUS_ACCURACY_HIGH: + return GeckoHalDefines.SENSOR_ACCURACY_HIGH; + } + return GeckoHalDefines.SENSOR_ACCURACY_UNKNOWN; + } + + @Override + public void onSensorChanged(SensorEvent s) { + int sensor_type = s.sensor.getType(); + int hal_type = 0; + float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f; + final int accuracy = HalSensorAccuracyFor(s.accuracy); + // SensorEvent timestamp is in nanoseconds, Gecko expects microseconds. + final long time = s.timestamp / 1000; + + switch (sensor_type) { + case Sensor.TYPE_ACCELEROMETER: + case Sensor.TYPE_LINEAR_ACCELERATION: + case Sensor.TYPE_ORIENTATION: + if (sensor_type == Sensor.TYPE_ACCELEROMETER) { + hal_type = GeckoHalDefines.SENSOR_ACCELERATION; + } else if (sensor_type == Sensor.TYPE_LINEAR_ACCELERATION) { + hal_type = GeckoHalDefines.SENSOR_LINEAR_ACCELERATION; + } else { + hal_type = GeckoHalDefines.SENSOR_ORIENTATION; + } + x = s.values[0]; + y = s.values[1]; + z = s.values[2]; + break; + + case Sensor.TYPE_GYROSCOPE: + hal_type = GeckoHalDefines.SENSOR_GYROSCOPE; + x = (float) Math.toDegrees(s.values[0]); + y = (float) Math.toDegrees(s.values[1]); + z = (float) Math.toDegrees(s.values[2]); + break; + + case Sensor.TYPE_PROXIMITY: + hal_type = GeckoHalDefines.SENSOR_PROXIMITY; + x = s.values[0]; + z = s.sensor.getMaximumRange(); + break; + + case Sensor.TYPE_LIGHT: + hal_type = GeckoHalDefines.SENSOR_LIGHT; + x = s.values[0]; + break; + + case Sensor.TYPE_ROTATION_VECTOR: + case Sensor.TYPE_GAME_ROTATION_VECTOR: // API >= 18 + hal_type = (sensor_type == Sensor.TYPE_ROTATION_VECTOR ? + GeckoHalDefines.SENSOR_ROTATION_VECTOR : + GeckoHalDefines.SENSOR_GAME_ROTATION_VECTOR); + x = s.values[0]; + y = s.values[1]; + z = s.values[2]; + if (s.values.length >= 4) { + w = s.values[3]; + } else { + // s.values[3] was optional in API <= 18, so we need to compute it + // The values form a unit quaternion, so we can compute the angle of + // rotation purely based on the given 3 values. + w = 1.0f - s.values[0] * s.values[0] - + s.values[1] * s.values[1] - s.values[2] * s.values[2]; + w = (w > 0.0f) ? (float) Math.sqrt(w) : 0.0f; + } + break; + } + + GeckoAppShell.onSensorChanged(hal_type, x, y, z, w, accuracy, time); + } + + // Geolocation. + @Override + public void onLocationChanged(Location location) { + // No logging here: user-identifying information. + GeckoAppShell.onLocationChanged(location.getLatitude(), location.getLongitude(), + location.getAltitude(), location.getAccuracy(), + location.getBearing(), location.getSpeed(), + location.getTime()); + } + + @Override + public void onProviderDisabled(String provider) + { + } + + @Override + public void onProviderEnabled(String provider) + { + } + + @Override + public void onStatusChanged(String provider, int status, Bundle extras) + { + } + + @Override // NotificationListener + public void showNotification(String name, String cookie, String host, + String title, String text, String imageUrl) { + // Default is to not show the notification, and immediate send close message. + GeckoAppShell.onNotificationClose(name, cookie); + } + + @Override // NotificationListener + public void showPersistentNotification(String name, String cookie, String host, + String title, String text, String imageUrl, + String data) { + // Default is to not show the notification, and immediate send close message. + GeckoAppShell.onNotificationClose(name, cookie); + } + + @Override // NotificationListener + public void closeNotification(String name) { + // Do nothing. + } + } + + private static final DefaultListeners DEFAULT_LISTENERS = new DefaultListeners(); + private static SensorEventListener sSensorListener = DEFAULT_LISTENERS; + private static LocationListener sLocationListener = DEFAULT_LISTENERS; + private static NotificationListener sNotificationListener = DEFAULT_LISTENERS; + + public static SensorEventListener getSensorListener() { + return sSensorListener; + } + + public static void setSensorListener(final SensorEventListener listener) { + sSensorListener = (listener != null) ? listener : DEFAULT_LISTENERS; + } + + public static LocationListener getLocationListener() { + return sLocationListener; + } + + public static void setLocationListener(final LocationListener listener) { + sLocationListener = (listener != null) ? listener : DEFAULT_LISTENERS; + } + + public static NotificationListener getNotificationListener() { + return sNotificationListener; + } + + public static void setNotificationListener(final NotificationListener listener) { + sNotificationListener = (listener != null) ? listener : DEFAULT_LISTENERS; + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableSensor(int aSensortype) { + GeckoInterface gi = getGeckoInterface(); + if (gi == null) { + return; + } + SensorManager sm = (SensorManager) + getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case GeckoHalDefines.SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor == null) { + gGameRotationVectorSensor = sm.getDefaultSensor(15); + // sm.getDefaultSensor( + // Sensor.TYPE_GAME_ROTATION_VECTOR); // API >= 18 + } + if (gGameRotationVectorSensor != null) { + sm.registerListener(getSensorListener(), + gGameRotationVectorSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + if (gGameRotationVectorSensor != null) { + break; + } + // Fallthrough + + case GeckoHalDefines.SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor == null) { + gRotationVectorSensor = sm.getDefaultSensor( + Sensor.TYPE_ROTATION_VECTOR); + } + if (gRotationVectorSensor != null) { + sm.registerListener(getSensorListener(), + gRotationVectorSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + if (gRotationVectorSensor != null) { + break; + } + // Fallthrough + + case GeckoHalDefines.SENSOR_ORIENTATION: + if (gOrientationSensor == null) { + gOrientationSensor = sm.getDefaultSensor( + Sensor.TYPE_ORIENTATION); + } + if (gOrientationSensor != null) { + sm.registerListener(getSensorListener(), + gOrientationSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case GeckoHalDefines.SENSOR_ACCELERATION: + if (gAccelerometerSensor == null) { + gAccelerometerSensor = sm.getDefaultSensor( + Sensor.TYPE_ACCELEROMETER); + } + if (gAccelerometerSensor != null) { + sm.registerListener(getSensorListener(), + gAccelerometerSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case GeckoHalDefines.SENSOR_PROXIMITY: + if (gProximitySensor == null) { + gProximitySensor = sm.getDefaultSensor(Sensor.TYPE_PROXIMITY); + } + if (gProximitySensor != null) { + sm.registerListener(getSensorListener(), + gProximitySensor, + SensorManager.SENSOR_DELAY_NORMAL); + } + break; + + case GeckoHalDefines.SENSOR_LIGHT: + if (gLightSensor == null) { + gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT); + } + if (gLightSensor != null) { + sm.registerListener(getSensorListener(), + gLightSensor, + SensorManager.SENSOR_DELAY_NORMAL); + } + break; + + case GeckoHalDefines.SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor == null) { + gLinearAccelerometerSensor = sm.getDefaultSensor( + Sensor.TYPE_LINEAR_ACCELERATION); + } + if (gLinearAccelerometerSensor != null) { + sm.registerListener(getSensorListener(), + gLinearAccelerometerSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case GeckoHalDefines.SENSOR_GYROSCOPE: + if (gGyroscopeSensor == null) { + gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + } + if (gGyroscopeSensor != null) { + sm.registerListener(getSensorListener(), + gGyroscopeSensor, + SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + default: + Log.w(LOGTAG, "Error! Can't enable unknown SENSOR type " + + aSensortype); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableSensor(int aSensortype) { + GeckoInterface gi = getGeckoInterface(); + if (gi == null) + return; + + SensorManager sm = (SensorManager) + getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case GeckoHalDefines.SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor != null) { + sm.unregisterListener(getSensorListener(), gGameRotationVectorSensor); + break; + } + // Fallthrough + + case GeckoHalDefines.SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor != null) { + sm.unregisterListener(getSensorListener(), gRotationVectorSensor); + break; + } + // Fallthrough + + case GeckoHalDefines.SENSOR_ORIENTATION: + if (gOrientationSensor != null) { + sm.unregisterListener(getSensorListener(), gOrientationSensor); + } + break; + + case GeckoHalDefines.SENSOR_ACCELERATION: + if (gAccelerometerSensor != null) { + sm.unregisterListener(getSensorListener(), gAccelerometerSensor); + } + break; + + case GeckoHalDefines.SENSOR_PROXIMITY: + if (gProximitySensor != null) { + sm.unregisterListener(getSensorListener(), gProximitySensor); + } + break; + + case GeckoHalDefines.SENSOR_LIGHT: + if (gLightSensor != null) { + sm.unregisterListener(getSensorListener(), gLightSensor); + } + break; + + case GeckoHalDefines.SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor != null) { + sm.unregisterListener(getSensorListener(), gLinearAccelerometerSensor); + } + break; + + case GeckoHalDefines.SENSOR_GYROSCOPE: + if (gGyroscopeSensor != null) { + sm.unregisterListener(getSensorListener(), gGyroscopeSensor); + } + break; + default: + Log.w(LOGTAG, "Error! Can't disable unknown SENSOR type " + aSensortype); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void moveTaskToBack() { + if (getGeckoInterface() != null) + getGeckoInterface().getActivity().moveTaskToBack(true); + } + + @WrapForJNI(calledFrom = "gecko") + public static void scheduleRestart() { + getGeckoInterface().doRestart(); + } + + // Creates a homescreen shortcut for a web page. + // This is the entry point from nsIShellService. + @WrapForJNI(calledFrom = "gecko") + public static void createShortcut(final String aTitle, final String aURI) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return; + } + geckoInterface.createShortcut(aTitle, aURI); + } + + @JNITarget + static public int getPreferredIconSize() { + ActivityManager am = (ActivityManager) + getApplicationContext().getSystemService(Context.ACTIVITY_SERVICE); + return am.getLauncherLargeIconSize(); + } + + @WrapForJNI(calledFrom = "gecko") + private static String[] getHandlersForMimeType(String aMimeType, String aAction) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return new String[] {}; + } + return geckoInterface.getHandlersForMimeType(aMimeType, aAction); + } + + @WrapForJNI(calledFrom = "gecko") + private static String[] getHandlersForURL(String aURL, String aAction) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return new String[] {}; + } + return geckoInterface.getHandlersForURL(aURL, aAction); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean getHWEncoderCapability() { + return HardwareCodecCapabilityUtils.getHWEncoderCapability(); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean getHWDecoderCapability() { + return HardwareCodecCapabilityUtils.getHWDecoderCapability(); + } + + static List<ResolveInfo> queryIntentActivities(Intent intent) { + final PackageManager pm = getApplicationContext().getPackageManager(); + + // Exclude any non-exported activities: we can't open them even if we want to! + // Bug 1031569 has some details. + final ArrayList<ResolveInfo> list = new ArrayList<>(); + for (ResolveInfo ri: pm.queryIntentActivities(intent, 0)) { + if (ri.activityInfo.exported) { + list.add(ri); + } + } + + return list; + } + + @WrapForJNI(calledFrom = "gecko") + public static String getExtensionFromMimeType(String aMimeType) { + return MimeTypeMap.getSingleton().getExtensionFromMimeType(aMimeType); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getMimeTypeFromExtensions(String aFileExt) { + StringTokenizer st = new StringTokenizer(aFileExt, ".,; "); + String type = null; + String subType = null; + while (st.hasMoreElements()) { + String ext = st.nextToken(); + String mt = getMimeTypeFromExtension(ext); + if (mt == null) + continue; + int slash = mt.indexOf('/'); + String tmpType = mt.substring(0, slash); + if (!tmpType.equalsIgnoreCase(type)) + type = type == null ? tmpType : "*"; + String tmpSubType = mt.substring(slash + 1); + if (!tmpSubType.equalsIgnoreCase(subType)) + subType = subType == null ? tmpSubType : "*"; + } + if (type == null) + type = "*"; + if (subType == null) + subType = "*"; + return type + "/" + subType; + } + + static boolean isUriSafeForScheme(Uri aUri) { + // Bug 794034 - We don't want to pass MWI or USSD codes to the + // dialer, and ensure the Uri class doesn't parse a URI + // containing a fragment ('#') + final String scheme = aUri.getScheme(); + if ("tel".equals(scheme) || "sms".equals(scheme)) { + final String number = aUri.getSchemeSpecificPart(); + if (number.contains("#") || number.contains("*") || aUri.getFragment() != null) { + return false; + } + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean openUriExternal(String targetURI, + String mimeType, + String packageName, + String className, + String action, + String title) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return false; + } + return geckoInterface.openUriExternal(targetURI, mimeType, packageName, className, action, title); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void notifyAlertListener(String name, String topic, String cookie); + + /** + * Called by the NotificationListener to notify Gecko that a notification has been + * shown. + */ + public static void onNotificationShow(final String name, final String cookie) { + if (GeckoThread.isRunning()) { + notifyAlertListener(name, "alertshow", cookie); + } + } + + /** + * Called by the NotificationListener to notify Gecko that a previously shown + * notification has been closed. + */ + public static void onNotificationClose(final String name, final String cookie) { + if (GeckoThread.isRunning()) { + notifyAlertListener(name, "alertfinished", cookie); + } + } + + /** + * Called by the NotificationListener to notify Gecko that a previously shown + * notification has been clicked on. + */ + public static void onNotificationClick(final String name, final String cookie) { + if (GeckoThread.isRunning()) { + notifyAlertListener(name, "alertclickcallback", cookie); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void showNotification(String name, String cookie, String title, + String text, String host, String imageUrl, + String persistentData) { + if (persistentData == null) { + getNotificationListener().showNotification(name, cookie, title, text, host, imageUrl); + return; + } + + getNotificationListener().showPersistentNotification( + name, cookie, title, text, host, imageUrl, persistentData); + } + + @WrapForJNI(calledFrom = "gecko") + private static void closeNotification(String name) { + getNotificationListener().closeNotification(name); + } + + @WrapForJNI(calledFrom = "gecko") + public static int getDpi() { + if (sDensityDpi == 0) { + sDensityDpi = getApplicationContext().getResources().getDisplayMetrics().densityDpi; + } + + return sDensityDpi; + } + + @WrapForJNI(calledFrom = "gecko") + private static float getDensity() { + return getApplicationContext().getResources().getDisplayMetrics().density; + } + + private static boolean isHighMemoryDevice() { + return HardwareUtils.getMemSize() > HIGH_MEMORY_DEVICE_THRESHOLD_MB; + } + + /** + * Returns the colour depth of the default screen. This will either be + * 24 or 16. + */ + @WrapForJNI(calledFrom = "gecko") + public static synchronized int getScreenDepth() { + if (sScreenDepth == 0) { + sScreenDepth = 16; + PixelFormat info = new PixelFormat(); + final WindowManager wm = (WindowManager) + getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + PixelFormat.getPixelFormatInfo(wm.getDefaultDisplay().getPixelFormat(), info); + if (info.bitsPerPixel >= 24 && isHighMemoryDevice()) { + sScreenDepth = 24; + } + } + + return sScreenDepth; + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized void setScreenDepthOverride(int aScreenDepth) { + if (sScreenDepth != 0) { + Log.e(LOGTAG, "Tried to override screen depth after it's already been set"); + return; + } + + sScreenDepth = aScreenDepth; + } + + @WrapForJNI(calledFrom = "gecko") + private static void setFullScreen(boolean fullscreen) { + if (getGeckoInterface() != null) + getGeckoInterface().setFullScreen(fullscreen); + } + + @WrapForJNI(calledFrom = "gecko") + private static void performHapticFeedback(boolean aIsLongPress) { + // Don't perform haptic feedback if a vibration is currently playing, + // because the haptic feedback will nuke the vibration. + if (!sVibrationMaybePlaying || System.nanoTime() >= sVibrationEndTime) { + LayerView layerView = getLayerView(); + layerView.performHapticFeedback(aIsLongPress ? + HapticFeedbackConstants.LONG_PRESS : + HapticFeedbackConstants.VIRTUAL_KEY); + } + } + + private static Vibrator vibrator() { + return (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE); + } + + // Helper method to convert integer array to long array. + private static long[] convertIntToLongArray(int[] input) { + long[] output = new long[input.length]; + for (int i = 0; i < input.length; i++) { + output[i] = input[i]; + } + return output; + } + + // Vibrate only if haptic feedback is enabled. + public static void vibrateOnHapticFeedbackEnabled(int[] milliseconds) { + if (Settings.System.getInt(getApplicationContext().getContentResolver(), + Settings.System.HAPTIC_FEEDBACK_ENABLED, 0) > 0) { + vibrate(convertIntToLongArray(milliseconds), -1); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(long milliseconds) { + sVibrationEndTime = System.nanoTime() + milliseconds * 1000000; + sVibrationMaybePlaying = true; + vibrator().vibrate(milliseconds); + } + + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(long[] pattern, int repeat) { + // If pattern.length is even, the last element in the pattern is a + // meaningless delay, so don't include it in vibrationDuration. + long vibrationDuration = 0; + int iterLen = pattern.length - (pattern.length % 2 == 0 ? 1 : 0); + for (int i = 0; i < iterLen; i++) { + vibrationDuration += pattern[i]; + } + + sVibrationEndTime = System.nanoTime() + vibrationDuration * 1000000; + sVibrationMaybePlaying = true; + vibrator().vibrate(pattern, repeat); + } + + @WrapForJNI(calledFrom = "gecko") + private static void cancelVibrate() { + sVibrationMaybePlaying = false; + sVibrationEndTime = 0; + vibrator().cancel(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void setKeepScreenOn(final boolean on) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // TODO + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkUp() { + ConnectivityManager cm = (ConnectivityManager) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + try { + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info == null || !info.isConnected()) + return false; + } catch (SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkKnown() { + ConnectivityManager cm = (ConnectivityManager) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + try { + if (cm.getActiveNetworkInfo() == null) + return false; + } catch (SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static int getNetworkLinkType() { + ConnectivityManager cm = (ConnectivityManager) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info == null) { + return LINK_TYPE_UNKNOWN; + } + + switch (info.getType()) { + case ConnectivityManager.TYPE_ETHERNET: + return LINK_TYPE_ETHERNET; + case ConnectivityManager.TYPE_WIFI: + return LINK_TYPE_WIFI; + case ConnectivityManager.TYPE_WIMAX: + return LINK_TYPE_WIMAX; + case ConnectivityManager.TYPE_MOBILE: + break; // We will handle sub-types after the switch. + default: + Log.w(LOGTAG, "Ignoring the current network type."); + return LINK_TYPE_UNKNOWN; + } + + TelephonyManager tm = (TelephonyManager) + getApplicationContext().getSystemService(Context.TELEPHONY_SERVICE); + if (tm == null) { + Log.e(LOGTAG, "Telephony service does not exist"); + return LINK_TYPE_UNKNOWN; + } + + switch (tm.getNetworkType()) { + case TelephonyManager.NETWORK_TYPE_IDEN: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_GPRS: + return LINK_TYPE_2G; + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_EDGE: + return LINK_TYPE_2G; // 2.5G + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + return LINK_TYPE_3G; + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + case TelephonyManager.NETWORK_TYPE_EHRPD: + return LINK_TYPE_3G; // 3.5G + case TelephonyManager.NETWORK_TYPE_HSPAP: + return LINK_TYPE_3G; // 3.75G + case TelephonyManager.NETWORK_TYPE_LTE: + return LINK_TYPE_4G; // 3.9G + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + Log.w(LOGTAG, "Connected to an unknown mobile network!"); + return LINK_TYPE_UNKNOWN; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static int[] getSystemColors() { + // attrsAppearance[] must correspond to AndroidSystemColors structure in android/AndroidBridge.h + final int[] attrsAppearance = { + android.R.attr.textColor, + android.R.attr.textColorPrimary, + android.R.attr.textColorPrimaryInverse, + android.R.attr.textColorSecondary, + android.R.attr.textColorSecondaryInverse, + android.R.attr.textColorTertiary, + android.R.attr.textColorTertiaryInverse, + android.R.attr.textColorHighlight, + android.R.attr.colorForeground, + android.R.attr.colorBackground, + android.R.attr.panelColorForeground, + android.R.attr.panelColorBackground + }; + + int[] result = new int[attrsAppearance.length]; + + final ContextThemeWrapper contextThemeWrapper = + new ContextThemeWrapper(getApplicationContext(), android.R.style.TextAppearance); + + final TypedArray appearance = contextThemeWrapper.getTheme().obtainStyledAttributes(attrsAppearance); + + if (appearance != null) { + for (int i = 0; i < appearance.getIndexCount(); i++) { + int idx = appearance.getIndex(i); + int color = appearance.getColor(idx, 0); + result[idx] = color; + } + appearance.recycle(); + } + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + public static void killAnyZombies() { + GeckoProcessesVisitor visitor = new GeckoProcessesVisitor() { + @Override + public boolean callback(int pid) { + if (pid != android.os.Process.myPid()) + android.os.Process.killProcess(pid); + return true; + } + }; + + EnumerateGeckoProcesses(visitor); + } + + interface GeckoProcessesVisitor { + boolean callback(int pid); + } + + private static void EnumerateGeckoProcesses(GeckoProcessesVisitor visiter) { + int pidColumn = -1; + int userColumn = -1; + + try { + // run ps and parse its output + java.lang.Process ps = Runtime.getRuntime().exec("ps"); + BufferedReader in = new BufferedReader(new InputStreamReader(ps.getInputStream()), + 2048); + + String headerOutput = in.readLine(); + + // figure out the column offsets. We only care about the pid and user fields + StringTokenizer st = new StringTokenizer(headerOutput); + + int tokenSoFar = 0; + while (st.hasMoreTokens()) { + String next = st.nextToken(); + if (next.equalsIgnoreCase("PID")) + pidColumn = tokenSoFar; + else if (next.equalsIgnoreCase("USER")) + userColumn = tokenSoFar; + tokenSoFar++; + } + + // alright, the rest are process entries. + String psOutput = null; + while ((psOutput = in.readLine()) != null) { + String[] split = psOutput.split("\\s+"); + if (split.length <= pidColumn || split.length <= userColumn) + continue; + int uid = android.os.Process.getUidForName(split[userColumn]); + if (uid == android.os.Process.myUid() && + !split[split.length - 1].equalsIgnoreCase("ps")) { + int pid = Integer.parseInt(split[pidColumn]); + boolean keepGoing = visiter.callback(pid); + if (keepGoing == false) + break; + } + } + in.close(); + } + catch (Exception e) { + Log.w(LOGTAG, "Failed to enumerate Gecko processes.", e); + } + } + + public static String getAppNameByPID(int pid) { + BufferedReader cmdlineReader = null; + String path = "/proc/" + pid + "/cmdline"; + try { + File cmdlineFile = new File(path); + if (!cmdlineFile.exists()) + return ""; + cmdlineReader = new BufferedReader(new FileReader(cmdlineFile)); + return cmdlineReader.readLine().trim(); + } catch (Exception ex) { + return ""; + } finally { + if (null != cmdlineReader) { + try { + cmdlineReader.close(); + } catch (Exception e) { } + } + } + } + + public static void listOfOpenFiles() { + int pidColumn = -1; + int nameColumn = -1; + + try { + String filter = GeckoProfile.get(getApplicationContext()).getDir().toString(); + Log.d(LOGTAG, "[OPENFILE] Filter: " + filter); + + // run lsof and parse its output + java.lang.Process lsof = Runtime.getRuntime().exec("lsof"); + BufferedReader in = new BufferedReader(new InputStreamReader(lsof.getInputStream()), 2048); + + String headerOutput = in.readLine(); + StringTokenizer st = new StringTokenizer(headerOutput); + int token = 0; + while (st.hasMoreTokens()) { + String next = st.nextToken(); + if (next.equalsIgnoreCase("PID")) + pidColumn = token; + else if (next.equalsIgnoreCase("NAME")) + nameColumn = token; + token++; + } + + // alright, the rest are open file entries. + Map<Integer, String> pidNameMap = new TreeMap<Integer, String>(); + String output = null; + while ((output = in.readLine()) != null) { + String[] split = output.split("\\s+"); + if (split.length <= pidColumn || split.length <= nameColumn) + continue; + final Integer pid = Integer.valueOf(split[pidColumn]); + String name = pidNameMap.get(pid); + if (name == null) { + name = getAppNameByPID(pid.intValue()); + pidNameMap.put(pid, name); + } + String file = split[nameColumn]; + if (!TextUtils.isEmpty(name) && !TextUtils.isEmpty(file) && file.startsWith(filter)) + Log.d(LOGTAG, "[OPENFILE] " + name + "(" + split[pidColumn] + ") : " + file); + } + in.close(); + } catch (Exception e) { } + } + + @WrapForJNI(calledFrom = "gecko") + private static byte[] getIconForExtension(String aExt, int iconSize) { + try { + if (iconSize <= 0) + iconSize = 16; + + if (aExt != null && aExt.length() > 1 && aExt.charAt(0) == '.') + aExt = aExt.substring(1); + + PackageManager pm = getApplicationContext().getPackageManager(); + Drawable icon = getDrawableForExtension(pm, aExt); + if (icon == null) { + // Use a generic icon + icon = pm.getDefaultActivityIcon(); + } + + Bitmap bitmap = ((BitmapDrawable)icon).getBitmap(); + if (bitmap.getWidth() != iconSize || bitmap.getHeight() != iconSize) + bitmap = Bitmap.createScaledBitmap(bitmap, iconSize, iconSize, true); + + ByteBuffer buf = ByteBuffer.allocate(iconSize * iconSize * 4); + bitmap.copyPixelsToBuffer(buf); + + return buf.array(); + } + catch (Exception e) { + Log.w(LOGTAG, "getIconForExtension failed.", e); + return null; + } + } + + public static String getMimeTypeFromExtension(String ext) { + final MimeTypeMap mtm = MimeTypeMap.getSingleton(); + return mtm.getMimeTypeFromExtension(ext); + } + + private static Drawable getDrawableForExtension(PackageManager pm, String aExt) { + Intent intent = new Intent(Intent.ACTION_VIEW); + final String mimeType = getMimeTypeFromExtension(aExt); + if (mimeType != null && mimeType.length() > 0) + intent.setType(mimeType); + else + return null; + + List<ResolveInfo> list = pm.queryIntentActivities(intent, 0); + if (list.size() == 0) + return null; + + ResolveInfo resolveInfo = list.get(0); + + if (resolveInfo == null) + return null; + + ActivityInfo activityInfo = resolveInfo.activityInfo; + + return activityInfo.loadIcon(pm); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean getShowPasswordSetting() { + try { + int showPassword = + Settings.System.getInt(getApplicationContext().getContentResolver(), + Settings.System.TEXT_SHOW_PASSWORD, 1); + return (showPassword > 0); + } + catch (Exception e) { + return true; + } + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public static native void onFullScreenPluginHidden(View view); + + @WrapForJNI(calledFrom = "gecko") + private static void addFullScreenPluginView(View view) { + if (getGeckoInterface() != null) + getGeckoInterface().addPluginView(view); + } + + @WrapForJNI(calledFrom = "gecko") + private static void removeFullScreenPluginView(View view) { + if (getGeckoInterface() != null) + getGeckoInterface().removePluginView(view); + } + + /** + * A plugin that wish to be loaded in the WebView must provide this permission + * in their AndroidManifest.xml. + */ + public static final String PLUGIN_ACTION = "android.webkit.PLUGIN"; + public static final String PLUGIN_PERMISSION = "android.webkit.permission.PLUGIN"; + + private static final String PLUGIN_SYSTEM_LIB = "/system/lib/plugins/"; + + private static final String PLUGIN_TYPE = "type"; + private static final String TYPE_NATIVE = "native"; + public static final ArrayList<PackageInfo> mPackageInfoCache = new ArrayList<>(); + + // Returns null if plugins are blocked on the device. + static String[] getPluginDirectories() { + + // Block on Pixel C. + if ((new File("/system/lib/hw/power.dragon.so")).exists()) { + Log.w(LOGTAG, "Blocking plugins because of Pixel C device (bug 1255122)"); + return null; + } + // An awful hack to detect Tegra devices. Easiest way to do it without spinning up a EGL context. + boolean isTegra = (new File("/system/lib/hw/gralloc.tegra.so")).exists() || + (new File("/system/lib/hw/gralloc.tegra3.so")).exists() || + (new File("/sys/class/nvidia-gpu")).exists(); + if (isTegra) { + // disable on KitKat (bug 957694) + if (Versions.feature19Plus) { + Log.w(LOGTAG, "Blocking plugins because of Tegra (bug 957694)"); + return null; + } + + // disable Flash on Tegra ICS with CM9 and other custom firmware (bug 736421) + final File vfile = new File("/proc/version"); + try { + if (vfile.canRead()) { + final BufferedReader reader = new BufferedReader(new FileReader(vfile)); + try { + final String version = reader.readLine(); + if (version.indexOf("CM9") != -1 || + version.indexOf("cyanogen") != -1 || + version.indexOf("Nova") != -1) { + Log.w(LOGTAG, "Blocking plugins because of Tegra 2 + unofficial ICS bug (bug 736421)"); + return null; + } + } finally { + reader.close(); + } + } + } catch (IOException ex) { + // Do nothing. + } + } + + ArrayList<String> directories = new ArrayList<String>(); + PackageManager pm = getApplicationContext().getPackageManager(); + List<ResolveInfo> plugins = pm.queryIntentServices(new Intent(PLUGIN_ACTION), + PackageManager.GET_META_DATA); + + synchronized (mPackageInfoCache) { + + // clear the list of existing packageInfo objects + mPackageInfoCache.clear(); + + + for (ResolveInfo info : plugins) { + + // retrieve the plugin's service information + ServiceInfo serviceInfo = info.serviceInfo; + if (serviceInfo == null) { + Log.w(LOGTAG, "Ignoring bad plugin."); + continue; + } + + // Blacklist HTC's flash lite. + // See bug #704516 - We're not quite sure what Flash Lite does, + // but loading it causes Flash to give errors and fail to draw. + if (serviceInfo.packageName.equals("com.htc.flashliteplugin")) { + Log.w(LOGTAG, "Skipping HTC's flash lite plugin"); + continue; + } + + + // Retrieve information from the plugin's manifest. + PackageInfo pkgInfo; + try { + pkgInfo = pm.getPackageInfo(serviceInfo.packageName, + PackageManager.GET_PERMISSIONS + | PackageManager.GET_SIGNATURES); + } catch (Exception e) { + Log.w(LOGTAG, "Can't find plugin: " + serviceInfo.packageName); + continue; + } + + if (pkgInfo == null) { + Log.w(LOGTAG, "Not loading plugin: " + serviceInfo.packageName + ". Could not load package information."); + continue; + } + + /* + * find the location of the plugin's shared library. The default + * is to assume the app is either a user installed app or an + * updated system app. In both of these cases the library is + * stored in the app's data directory. + */ + String directory = pkgInfo.applicationInfo.dataDir + "/lib"; + final int appFlags = pkgInfo.applicationInfo.flags; + final int updatedSystemFlags = ApplicationInfo.FLAG_SYSTEM | + ApplicationInfo.FLAG_UPDATED_SYSTEM_APP; + + // preloaded system app with no user updates + if ((appFlags & updatedSystemFlags) == ApplicationInfo.FLAG_SYSTEM) { + directory = PLUGIN_SYSTEM_LIB + pkgInfo.packageName; + } + + // check if the plugin has the required permissions + String permissions[] = pkgInfo.requestedPermissions; + if (permissions == null) { + Log.w(LOGTAG, "Not loading plugin: " + serviceInfo.packageName + ". Does not have required permission."); + continue; + } + boolean permissionOk = false; + for (String permit : permissions) { + if (PLUGIN_PERMISSION.equals(permit)) { + permissionOk = true; + break; + } + } + if (!permissionOk) { + Log.w(LOGTAG, "Not loading plugin: " + serviceInfo.packageName + ". Does not have required permission (2)."); + continue; + } + + // check to ensure the plugin is properly signed + Signature signatures[] = pkgInfo.signatures; + if (signatures == null) { + Log.w(LOGTAG, "Not loading plugin: " + serviceInfo.packageName + ". Not signed."); + continue; + } + + // determine the type of plugin from the manifest + if (serviceInfo.metaData == null) { + Log.e(LOGTAG, "The plugin '" + serviceInfo.name + "' has no defined type."); + continue; + } + + String pluginType = serviceInfo.metaData.getString(PLUGIN_TYPE); + if (!TYPE_NATIVE.equals(pluginType)) { + Log.e(LOGTAG, "Unrecognized plugin type: " + pluginType); + continue; + } + + try { + Class<?> cls = getPluginClass(serviceInfo.packageName, serviceInfo.name); + + //TODO implement any requirements of the plugin class here! + boolean classFound = true; + + if (!classFound) { + Log.e(LOGTAG, "The plugin's class' " + serviceInfo.name + "' does not extend the appropriate class."); + continue; + } + + } catch (NameNotFoundException e) { + Log.e(LOGTAG, "Can't find plugin: " + serviceInfo.packageName); + continue; + } catch (ClassNotFoundException e) { + Log.e(LOGTAG, "Can't find plugin's class: " + serviceInfo.name); + continue; + } + + // if all checks have passed then make the plugin available + mPackageInfoCache.add(pkgInfo); + directories.add(directory); + } + } + + return directories.toArray(new String[directories.size()]); + } + + static String getPluginPackage(String pluginLib) { + + if (pluginLib == null || pluginLib.length() == 0) { + return null; + } + + synchronized (mPackageInfoCache) { + for (PackageInfo pkgInfo : mPackageInfoCache) { + if (pluginLib.contains(pkgInfo.packageName)) { + return pkgInfo.packageName; + } + } + } + + return null; + } + + static Class<?> getPluginClass(String packageName, String className) + throws NameNotFoundException, ClassNotFoundException { + Context pluginContext = getApplicationContext().createPackageContext(packageName, + Context.CONTEXT_INCLUDE_CODE | + Context.CONTEXT_IGNORE_SECURITY); + ClassLoader pluginCL = pluginContext.getClassLoader(); + return pluginCL.loadClass(className); + } + + @WrapForJNI + private static Class<?> loadPluginClass(String className, String libName) { + if (getGeckoInterface() == null) + return null; + try { + final String packageName = getPluginPackage(libName); + final int contextFlags = Context.CONTEXT_INCLUDE_CODE | Context.CONTEXT_IGNORE_SECURITY; + final Context pluginContext = getApplicationContext().createPackageContext( + packageName, contextFlags); + return pluginContext.getClassLoader().loadClass(className); + } catch (java.lang.ClassNotFoundException cnfe) { + Log.w(LOGTAG, "Couldn't find plugin class " + className, cnfe); + return null; + } catch (android.content.pm.PackageManager.NameNotFoundException nnfe) { + Log.w(LOGTAG, "Couldn't find package.", nnfe); + return null; + } + } + + private static Context sApplicationContext; + private static ContextGetter sContextGetter; + + @Deprecated + @WrapForJNI + public static Context getContext() { + return sContextGetter.getContext(); + } + + public static void setContextGetter(ContextGetter cg) { + sContextGetter = cg; + } + + @WrapForJNI + public static Context getApplicationContext() { + return sApplicationContext; + } + + public static void setApplicationContext(final Context context) { + sApplicationContext = context; + } + + public static SharedPreferences getSharedPreferences() { + if (sContextGetter == null) { + throw new IllegalStateException("No ContextGetter; cannot fetch prefs."); + } + return sContextGetter.getSharedPreferences(); + } + + public interface AppStateListener { + public void onPause(); + public void onResume(); + public void onOrientationChanged(); + } + + public interface GeckoInterface { + public EventDispatcher getAppEventDispatcher(); + public GeckoProfile getProfile(); + public Activity getActivity(); + public String getDefaultUAString(); + public void doRestart(); + public void setFullScreen(boolean fullscreen); + public void addPluginView(View view); + public void removePluginView(final View view); + public void enableOrientationListener(); + public void disableOrientationListener(); + public void addAppStateListener(AppStateListener listener); + public void removeAppStateListener(AppStateListener listener); + public void notifyWakeLockChanged(String topic, String state); + public boolean areTabsShown(); + public AbsoluteLayout getPluginContainer(); + public void notifyCheckUpdateResult(String result); + public void invalidateOptionsMenu(); + + /** + * Create a shortcut -- generally a home-screen icon -- linking the given title to the given URI. + * <p> + * This method is always invoked on the Gecko thread. + * + * @param title of URI to link to. + * @param URI to link to. + */ + public void createShortcut(String title, String URI); + + /** + * Check if the given URI is visited. + * <p/> + * If it has been visited, call {@link GeckoAppShell#notifyUriVisited(String)}. (If it + * has not been visited, do nothing.) + * <p/> + * This method is always invoked on the Gecko thread. + * + * @param uri to check. + */ + public void checkUriVisited(String uri); + + /** + * Mark the given URI as visited in Gecko. + * <p/> + * Implementors may maintain some local store of visited URIs in order to be able to + * answer {@link #checkUriVisited(String)} requests affirmatively. + * <p/> + * This method is always invoked on the Gecko thread. + * + * @param uri to mark. + */ + public void markUriVisited(final String uri); + + /** + * Set the title of the given URI, as determined by Gecko. + * <p/> + * This method is always invoked on the Gecko thread. + * + * @param uri given. + * @param title to associate with the given URI. + */ + public void setUriTitle(final String uri, final String title); + + public void setAccessibilityEnabled(boolean enabled); + + public boolean openUriExternal(String targetURI, String mimeType, String packageName, String className, String action, String title); + + public String[] getHandlersForMimeType(String mimeType, String action); + public String[] getHandlersForURL(String url, String action); + + /** + * URI of the underlying chrome window to be opened, or null to use the default GeckoView + * XUL container <tt>chrome://browser/content/geckoview.xul</tt>. See + * <a href="https://developer.mozilla.org/en/docs/toolkit.defaultChromeURI">https://developer.mozilla.org/en/docs/toolkit.defaultChromeURI</a> + * + * @return URI or null. + */ + String getDefaultChromeURI(); + }; + + private static GeckoInterface sGeckoInterface; + + public static GeckoInterface getGeckoInterface() { + return sGeckoInterface; + } + + public static void setGeckoInterface(GeckoInterface aGeckoInterface) { + sGeckoInterface = aGeckoInterface; + } + + /* package */ static Camera sCamera; + + private static final int kPreferredFPS = 25; + private static byte[] sCameraBuffer; + + private static class CameraCallback implements Camera.PreviewCallback { + @WrapForJNI(calledFrom = "gecko") + private static native void onFrameData(int camera, byte[] data); + + private final int mCamera; + + public CameraCallback(int camera) { + mCamera = camera; + } + + @Override + public void onPreviewFrame(byte[] data, Camera camera) { + onFrameData(mCamera, data); + + if (sCamera != null) { + sCamera.addCallbackBuffer(sCameraBuffer); + } + } + } + + @WrapForJNI(calledFrom = "gecko") + private static int[] initCamera(String aContentType, int aCamera, int aWidth, int aHeight) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + try { + if (getGeckoInterface() != null) + getGeckoInterface().enableOrientationListener(); + } catch (Exception e) { } + } + }); + + // [0] = 0|1 (failure/success) + // [1] = width + // [2] = height + // [3] = fps + int[] result = new int[4]; + result[0] = 0; + + if (Camera.getNumberOfCameras() == 0) { + return result; + } + + try { + sCamera = Camera.open(aCamera); + + Camera.Parameters params = sCamera.getParameters(); + params.setPreviewFormat(ImageFormat.NV21); + + // use the preview fps closest to 25 fps. + int fpsDelta = 1000; + try { + Iterator<Integer> it = params.getSupportedPreviewFrameRates().iterator(); + while (it.hasNext()) { + int nFps = it.next(); + if (Math.abs(nFps - kPreferredFPS) < fpsDelta) { + fpsDelta = Math.abs(nFps - kPreferredFPS); + params.setPreviewFrameRate(nFps); + } + } + } catch (Exception e) { + params.setPreviewFrameRate(kPreferredFPS); + } + + // set up the closest preview size available + Iterator<Camera.Size> sit = params.getSupportedPreviewSizes().iterator(); + int sizeDelta = 10000000; + int bufferSize = 0; + while (sit.hasNext()) { + Camera.Size size = sit.next(); + if (Math.abs(size.width * size.height - aWidth * aHeight) < sizeDelta) { + sizeDelta = Math.abs(size.width * size.height - aWidth * aHeight); + params.setPreviewSize(size.width, size.height); + bufferSize = size.width * size.height; + } + } + + sCamera.setParameters(params); + sCameraBuffer = new byte[(bufferSize * 12) / 8]; + sCamera.addCallbackBuffer(sCameraBuffer); + sCamera.setPreviewCallbackWithBuffer(new CameraCallback(aCamera)); + sCamera.startPreview(); + params = sCamera.getParameters(); + result[0] = 1; + result[1] = params.getPreviewSize().width; + result[2] = params.getPreviewSize().height; + result[3] = params.getPreviewFrameRate(); + } catch (RuntimeException e) { + Log.w(LOGTAG, "initCamera RuntimeException.", e); + result[0] = result[1] = result[2] = result[3] = 0; + } + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized void closeCamera() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + try { + if (getGeckoInterface() != null) + getGeckoInterface().disableOrientationListener(); + } catch (Exception e) { } + } + }); + if (sCamera != null) { + sCamera.stopPreview(); + sCamera.release(); + sCamera = null; + sCameraBuffer = null; + } + } + + /* + * Battery API related methods. + */ + @WrapForJNI(calledFrom = "gecko") + private static void enableBatteryNotifications() { + GeckoBatteryManager.enableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void handleGeckoMessage(final NativeJSContainer message) { + boolean success = EventDispatcher.getInstance().dispatchEvent(message); + if (getGeckoInterface() != null && getGeckoInterface().getAppEventDispatcher() != null) { + success |= getGeckoInterface().getAppEventDispatcher().dispatchEvent(message); + } + + if (!success) { + final String type = message.optString("type", null); + final String guid = message.optString(EventDispatcher.GUID, null); + if (type != null && guid != null) { + (new EventDispatcher.GeckoEventCallback(guid, type)).sendError("No listeners for request"); + } + } + message.disposeNative(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableBatteryNotifications() { + GeckoBatteryManager.disableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentBatteryInformation() { + return GeckoBatteryManager.getCurrentInformation(); + } + + @WrapForJNI(stubName = "CheckURIVisited", calledFrom = "gecko") + private static void checkUriVisited(String uri) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return; + } + geckoInterface.checkUriVisited(uri); + } + + @WrapForJNI(stubName = "MarkURIVisited", calledFrom = "gecko") + private static void markUriVisited(final String uri) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return; + } + geckoInterface.markUriVisited(uri); + } + + @WrapForJNI(stubName = "SetURITitle", calledFrom = "gecko") + private static void setUriTitle(final String uri, final String title) { + final GeckoInterface geckoInterface = getGeckoInterface(); + if (geckoInterface == null) { + return; + } + geckoInterface.setUriTitle(uri, title); + } + + @WrapForJNI(calledFrom = "gecko") + private static void hideProgressDialog() { + // unused stub + } + + /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */ + @WrapForJNI(calledFrom = "gecko") + @RobocopTarget + public static boolean isTablet() { + return HardwareUtils.isTablet(); + } + + private static boolean sImeWasEnabledOnLastResize = false; + public static void viewSizeChanged() { + GeckoView v = (GeckoView) getLayerView(); + if (v == null) { + return; + } + boolean imeIsEnabled = v.isIMEEnabled(); + if (imeIsEnabled && !sImeWasEnabledOnLastResize) { + // The IME just came up after not being up, so let's scroll + // to the focused input. + notifyObservers("ScrollTo:FocusedInput", ""); + } + sImeWasEnabledOnLastResize = imeIsEnabled; + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentNetworkInformation() { + return GeckoNetworkManager.getInstance().getCurrentInformation(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableNetworkNotifications() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + GeckoNetworkManager.getInstance().enableNotifications(); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableNetworkNotifications() { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + GeckoNetworkManager.getInstance().disableNotifications(); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private static short getScreenOrientation() { + return GeckoScreenOrientation.getInstance().getScreenOrientation().value; + } + + @WrapForJNI(calledFrom = "gecko") + private static int getScreenAngle() { + return GeckoScreenOrientation.getInstance().getAngle(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableScreenOrientationNotifications() { + GeckoScreenOrientation.getInstance().enableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableScreenOrientationNotifications() { + GeckoScreenOrientation.getInstance().disableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void lockScreenOrientation(int aOrientation) { + GeckoScreenOrientation.getInstance().lock(aOrientation); + } + + @WrapForJNI(calledFrom = "gecko") + private static void unlockScreenOrientation() { + GeckoScreenOrientation.getInstance().unlock(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void notifyWakeLockChanged(String topic, String state) { + if (getGeckoInterface() != null) + getGeckoInterface().notifyWakeLockChanged(topic, state); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean unlockProfile() { + // Try to kill any zombie Fennec's that might be running + GeckoAppShell.killAnyZombies(); + + // Then force unlock this profile + if (getGeckoInterface() != null) { + GeckoProfile profile = getGeckoInterface().getProfile(); + File lock = profile.getFile(".parentlock"); + return lock.exists() && lock.delete(); + } + return false; + } + + @WrapForJNI(calledFrom = "gecko") + private static String getProxyForURI(String spec, String scheme, String host, int port) { + final ProxySelector ps = new ProxySelector(); + + Proxy proxy = ps.select(scheme, host); + if (Proxy.NO_PROXY.equals(proxy)) { + return "DIRECT"; + } + + switch (proxy.type()) { + case HTTP: + return "PROXY " + proxy.address().toString(); + case SOCKS: + return "SOCKS " + proxy.address().toString(); + } + + return "DIRECT"; + } + + @WrapForJNI + private static InputStream createInputStream(URLConnection connection) throws IOException { + return connection.getInputStream(); + } + + private static class BitmapConnection extends URLConnection { + private Bitmap bitmap; + + BitmapConnection(Bitmap b) throws MalformedURLException, IOException { + super(null); + bitmap = b; + } + + @Override + public void connect() {} + + @Override + public InputStream getInputStream() throws IOException { + return new BitmapInputStream(); + } + + @Override + public String getContentType() { + return "image/png"; + } + + private final class BitmapInputStream extends PipedInputStream { + private boolean mHaveConnected = false; + + @Override + public synchronized int read(byte[] buffer, int byteOffset, int byteCount) + throws IOException { + if (mHaveConnected) { + return super.read(buffer, byteOffset, byteCount); + } + + final PipedOutputStream output = new PipedOutputStream(); + connect(output); + ThreadUtils.postToBackgroundThread( + new Runnable() { + @Override + public void run() { + try { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, output); + output.close(); + } catch (IOException ioe) { } + } + }); + mHaveConnected = true; + return super.read(buffer, byteOffset, byteCount); + } + } + } + + @WrapForJNI + private static URLConnection getConnection(String url) { + try { + String spec; + if (url.startsWith("android://")) { + spec = url.substring(10); + } else { + spec = url.substring(8); + } + + // Check if we are loading a package icon. + try { + if (spec.startsWith("icon/")) { + String[] splits = spec.split("/"); + if (splits.length != 2) { + return null; + } + final String pkg = splits[1]; + final PackageManager pm = getApplicationContext().getPackageManager(); + final Drawable d = pm.getApplicationIcon(pkg); + final Bitmap bitmap = BitmapUtils.getBitmapFromDrawable(d); + return new BitmapConnection(bitmap); + } + } catch (Exception ex) { + Log.e(LOGTAG, "error", ex); + } + + // if the colon got stripped, put it back + int colon = spec.indexOf(':'); + if (colon == -1 || colon > spec.indexOf('/')) { + spec = spec.replaceFirst("/", ":/"); + } + } catch (Exception ex) { + return null; + } + return null; + } + + @WrapForJNI + private static String connectionGetMimeType(URLConnection connection) { + return connection.getContentType(); + } + + @WrapForJNI(calledFrom = "gecko") + private static int getMaxTouchPoints() { + PackageManager pm = getApplicationContext().getPackageManager(); + if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_JAZZHAND)) { + // at least, 5+ fingers. + return 5; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH_DISTINCT)) { + // at least, 2+ fingers. + return 2; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN_MULTITOUCH)) { + // 2 fingers + return 2; + } else if (pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN)) { + // 1 finger + return 1; + } + return 0; + } + + public static synchronized void resetScreenSize() { + sScreenSize = null; + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized Rect getScreenSize() { + if (sScreenSize == null) { + final WindowManager wm = (WindowManager) + getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Display disp = wm.getDefaultDisplay(); + sScreenSize = new Rect(0, 0, disp.getWidth(), disp.getHeight()); + } + return sScreenSize; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java new file mode 100644 index 000000000..1a41c390a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java @@ -0,0 +1,202 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; + +import org.mozilla.gecko.annotation.WrapForJNI; + +public class GeckoBatteryManager extends BroadcastReceiver { + private static final String LOGTAG = "GeckoBatteryManager"; + + // Those constants should be keep in sync with the ones in: + // dom/battery/Constants.h + private final static double kDefaultLevel = 1.0; + private final static boolean kDefaultCharging = true; + private final static double kDefaultRemainingTime = 0.0; + private final static double kUnknownRemainingTime = -1.0; + + private static long sLastLevelChange; + private static boolean sNotificationsEnabled; + private static double sLevel = kDefaultLevel; + private static boolean sCharging = kDefaultCharging; + private static double sRemainingTime = kDefaultRemainingTime; + + private static final GeckoBatteryManager sInstance = new GeckoBatteryManager(); + + private final IntentFilter mFilter; + private Context mApplicationContext; + private boolean mIsEnabled; + + public static GeckoBatteryManager getInstance() { + return sInstance; + } + + private GeckoBatteryManager() { + mFilter = new IntentFilter(); + mFilter.addAction(Intent.ACTION_BATTERY_CHANGED); + } + + public synchronized void start(final Context context) { + if (mIsEnabled) { + Log.w(LOGTAG, "Already started!"); + return; + } + + mApplicationContext = context.getApplicationContext(); + // registerReceiver will return null if registering fails. + if (mApplicationContext.registerReceiver(this, mFilter) == null) { + Log.e(LOGTAG, "Registering receiver failed"); + } else { + mIsEnabled = true; + } + } + + public synchronized void stop() { + if (!mIsEnabled) { + Log.w(LOGTAG, "Already stopped!"); + return; + } + + mApplicationContext.unregisterReceiver(this); + mApplicationContext = null; + mIsEnabled = false; + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + private static native void onBatteryChange(double level, boolean charging, + double remainingTime); + + @Override + public void onReceive(Context context, Intent intent) { + if (!intent.getAction().equals(Intent.ACTION_BATTERY_CHANGED)) { + Log.e(LOGTAG, "Got an unexpected intent!"); + return; + } + + boolean previousCharging = isCharging(); + double previousLevel = getLevel(); + + // NOTE: it might not be common (in 2012) but technically, Android can run + // on a device that has no battery so we want to make sure it's not the case + // before bothering checking for battery state. + // However, the Galaxy Nexus phone advertises itself as battery-less which + // force us to special-case the logic. + // See the Google bug: https://code.google.com/p/android/issues/detail?id=22035 + if (intent.getBooleanExtra(BatteryManager.EXTRA_PRESENT, false) || + Build.MODEL.equals("Galaxy Nexus")) { + int plugged = intent.getIntExtra(BatteryManager.EXTRA_PLUGGED, -1); + if (plugged == -1) { + sCharging = kDefaultCharging; + Log.e(LOGTAG, "Failed to get the plugged status!"); + } else { + // Likely, if plugged > 0, it's likely plugged and charging but the doc + // isn't clear about that. + sCharging = plugged != 0; + } + + if (sCharging != previousCharging) { + sRemainingTime = kUnknownRemainingTime; + // The new remaining time is going to take some time to show up but + // it's the best way to show a not too wrong value. + sLastLevelChange = 0; + } + + // We need two doubles because sLevel is a double. + double current = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + double max = intent.getIntExtra(BatteryManager.EXTRA_SCALE, -1); + if (current == -1 || max == -1) { + Log.e(LOGTAG, "Failed to get battery level!"); + sLevel = kDefaultLevel; + } else { + sLevel = current / max; + } + + if (sLevel == 1.0 && sCharging) { + sRemainingTime = kDefaultRemainingTime; + } else if (sLevel != previousLevel) { + // Estimate remaining time. + if (sLastLevelChange != 0) { + // Use elapsedRealtime() because we want to track time across device sleeps. + long currentTime = SystemClock.elapsedRealtime(); + long dt = (currentTime - sLastLevelChange) / 1000; + double dLevel = sLevel - previousLevel; + + if (sCharging) { + if (dLevel < 0) { + sRemainingTime = kUnknownRemainingTime; + } else { + sRemainingTime = Math.round(dt / dLevel * (1.0 - sLevel)); + } + } else { + if (dLevel > 0) { + Log.w(LOGTAG, "When discharging, level should decrease!"); + sRemainingTime = kUnknownRemainingTime; + } else { + sRemainingTime = Math.round(dt / -dLevel * sLevel); + } + } + + sLastLevelChange = currentTime; + } else { + // That's the first time we got an update, we can't do anything. + sLastLevelChange = SystemClock.elapsedRealtime(); + } + } + } else { + sLevel = kDefaultLevel; + sCharging = kDefaultCharging; + sRemainingTime = kDefaultRemainingTime; + } + + /* + * We want to inform listeners if the following conditions are fulfilled: + * - we have at least one observer; + * - the charging state or the level has changed. + * + * Note: no need to check for a remaining time change given that it's only + * updated if there is a level change or a charging change. + * + * The idea is to prevent doing all the way to the DOM code in the child + * process to finally not send an event. + */ + if (sNotificationsEnabled && + (previousCharging != isCharging() || previousLevel != getLevel())) { + onBatteryChange(getLevel(), isCharging(), getRemainingTime()); + } + } + + public static boolean isCharging() { + return sCharging; + } + + public static double getLevel() { + return sLevel; + } + + public static double getRemainingTime() { + return sRemainingTime; + } + + public static void enableNotifications() { + sNotificationsEnabled = true; + } + + public static void disableNotifications() { + sNotificationsEnabled = false; + } + + public static double[] getCurrentInformation() { + return new double[] { getLevel(), isCharging() ? 1.0 : 0.0, getRemainingTime() }; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditable.java new file mode 100644 index 000000000..695cff443 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditable.java @@ -0,0 +1,1589 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.json.JSONObject; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; + +import android.graphics.RectF; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.InputFilter; +import android.text.NoCopySpan; +import android.text.Selection; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextPaint; +import android.text.TextUtils; +import android.text.style.CharacterStyle; +import android.util.Log; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; + +/* + GeckoEditable implements only some functions of Editable + The field mText contains the actual underlying + SpannableStringBuilder/Editable that contains our text. +*/ +final class GeckoEditable extends JNIObject + implements InvocationHandler, Editable, GeckoEditableClient { + + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoEditable"; + + // Filters to implement Editable's filtering functionality + private InputFilter[] mFilters; + + private final AsyncText mText; + private final Editable mProxy; + private final ConcurrentLinkedQueue<Action> mActions; + private KeyCharacterMap mKeyMap; + + // mIcRunHandler is the Handler that currently runs Gecko-to-IC Runnables + // mIcPostHandler is the Handler to post Gecko-to-IC Runnables to + // The two can be different when switching from one handler to another + private Handler mIcRunHandler; + private Handler mIcPostHandler; + + /* package */ GeckoEditableListener mListener; + /* package */ GeckoView mView; + + /* package */ boolean mInBatchMode; // Used by IC thread + /* package */ boolean mNeedSync; // Used by IC thread + // Gecko side needs an updated composition from Java; + private boolean mNeedUpdateComposition; // Used by IC thread + private boolean mSuppressKeyUp; // Used by IC thread + + private boolean mGeckoFocused; // Used by Gecko thread + private boolean mIgnoreSelectionChange; // Used by Gecko thread + + private static final int IME_RANGE_CARETPOSITION = 1; + private static final int IME_RANGE_RAWINPUT = 2; + private static final int IME_RANGE_SELECTEDRAWTEXT = 3; + private static final int IME_RANGE_CONVERTEDTEXT = 4; + private static final int IME_RANGE_SELECTEDCONVERTEDTEXT = 5; + + private static final int IME_RANGE_LINE_NONE = 0; + private static final int IME_RANGE_LINE_DOTTED = 1; + private static final int IME_RANGE_LINE_DASHED = 2; + private static final int IME_RANGE_LINE_SOLID = 3; + private static final int IME_RANGE_LINE_DOUBLE = 4; + private static final int IME_RANGE_LINE_WAVY = 5; + + private static final int IME_RANGE_UNDERLINE = 1; + private static final int IME_RANGE_FORECOLOR = 2; + private static final int IME_RANGE_BACKCOLOR = 4; + private static final int IME_RANGE_LINECOLOR = 8; + + @WrapForJNI(dispatchTo = "proxy") + private native void onKeyEvent(int action, int keyCode, int scanCode, int metaState, + long time, int unicodeChar, int baseUnicodeChar, + int domPrintableKeyValue, int repeatCount, int flags, + boolean isSynthesizedImeKey, KeyEvent event); + + private void onKeyEvent(KeyEvent event, int action, int savedMetaState, + boolean isSynthesizedImeKey) { + // Use a separate action argument so we can override the key's original action, + // e.g. change ACTION_MULTIPLE to ACTION_DOWN. That way we don't have to allocate + // a new key event just to change its action field. + // + // Normally we expect event.getMetaState() to reflect the current meta-state; however, + // some software-generated key events may not have event.getMetaState() set, e.g. key + // events from Swype. Therefore, it's necessary to combine the key's meta-states + // with the meta-states that we keep separately in KeyListener + final int metaState = event.getMetaState() | savedMetaState; + final int unmodifiedMetaState = metaState & + ~(KeyEvent.META_ALT_MASK | KeyEvent.META_CTRL_MASK | KeyEvent.META_META_MASK); + final int unicodeChar = event.getUnicodeChar(metaState); + final int domPrintableKeyValue = + unicodeChar >= ' ' ? unicodeChar : + unmodifiedMetaState != metaState ? event.getUnicodeChar(unmodifiedMetaState) : + 0; + onKeyEvent(action, event.getKeyCode(), event.getScanCode(), + metaState, event.getEventTime(), unicodeChar, + // e.g. for Ctrl+A, Android returns 0 for unicodeChar, + // but Gecko expects 'a', so we return that in baseUnicodeChar. + event.getUnicodeChar(0), domPrintableKeyValue, event.getRepeatCount(), + event.getFlags(), isSynthesizedImeKey, event); + } + + @WrapForJNI(dispatchTo = "proxy") + private native void onImeSynchronize(); + + @WrapForJNI(dispatchTo = "proxy") + private native void onImeReplaceText(int start, int end, String text); + + @WrapForJNI(dispatchTo = "proxy") + private native void onImeAddCompositionRange(int start, int end, int rangeType, + int rangeStyles, int rangeLineStyle, + boolean rangeBoldLine, int rangeForeColor, + int rangeBackColor, int rangeLineColor); + + @WrapForJNI(dispatchTo = "proxy") + private native void onImeUpdateComposition(int start, int end); + + @WrapForJNI(dispatchTo = "proxy") + private native void onImeRequestCursorUpdates(int requestMode); + + /** + * Class that encapsulates asynchronous text editing. There are two copies of the + * text, a current copy and a shadow copy. Both can be modified independently through + * the current*** and shadow*** methods, respectively. The current copy can only be + * modified on the Gecko side and reflects the authoritative version of the text. The + * shadow copy can only be modified on the IC side and reflects what we think the + * current text is. Periodically, the shadow copy can be synced to the current copy + * through syncShadowText, so the shadow copy once again refers to the same text as + * the current copy. + */ + private final class AsyncText { + // The current text is the update-to-date version of the text, and is only updated + // on the Gecko side. + private final SpannableStringBuilder mCurrentText = new SpannableStringBuilder(); + // Track changes on the current side for syncing purposes. + // Start of the changed range in current text since last sync. + private int mCurrentStart = Integer.MAX_VALUE; + // End of the changed range (before the change) in current text since last sync. + private int mCurrentOldEnd; + // End of the changed range (after the change) in current text since last sync. + private int mCurrentNewEnd; + // Track selection changes separately. + private boolean mCurrentSelectionChanged; + + // The shadow text is what we think the current text is on the Java side, and is + // periodically synced with the current text. + private final SpannableStringBuilder mShadowText = new SpannableStringBuilder(); + // Track changes on the shadow side for syncing purposes. + // Start of the changed range in shadow text since last sync. + private int mShadowStart = Integer.MAX_VALUE; + // End of the changed range (before the change) in shadow text since last sync. + private int mShadowOldEnd; + // End of the changed range (after the change) in shadow text since last sync. + private int mShadowNewEnd; + + private void addCurrentChangeLocked(final int start, final int oldEnd, final int newEnd) { + // Merge the new change into any existing change. + mCurrentStart = Math.min(mCurrentStart, start); + mCurrentOldEnd += Math.max(0, oldEnd - mCurrentNewEnd); + mCurrentNewEnd = newEnd + Math.max(0, mCurrentNewEnd - oldEnd); + } + + public synchronized void currentReplace(final int start, final int end, + final CharSequence newText) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + mCurrentText.replace(start, end, newText); + addCurrentChangeLocked(start, end, start + newText.length()); + } + + public synchronized void currentSetSelection(final int start, final int end) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + Selection.setSelection(mCurrentText, start, end); + mCurrentSelectionChanged = true; + } + + public synchronized void currentSetSpan(final Object obj, final int start, + final int end, final int flags) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + mCurrentText.setSpan(obj, start, end, flags); + addCurrentChangeLocked(start, end, end); + } + + public synchronized void currentRemoveSpan(final Object obj) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + if (obj == null) { + mCurrentText.clearSpans(); + addCurrentChangeLocked(0, mCurrentText.length(), mCurrentText.length()); + return; + } + final int start = mCurrentText.getSpanStart(obj); + final int end = mCurrentText.getSpanEnd(obj); + if (start < 0 || end < 0) { + return; + } + mCurrentText.removeSpan(obj); + addCurrentChangeLocked(start, end, end); + } + + // Return Spanned instead of Editable because the returned object is supposed to + // be read-only. Editing should be done through one of the current*** methods. + public Spanned getCurrentText() { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + return mCurrentText; + } + + private void addShadowChange(final int start, final int oldEnd, final int newEnd) { + // Merge the new change into any existing change. + mShadowStart = Math.min(mShadowStart, start); + mShadowOldEnd += Math.max(0, oldEnd - mShadowNewEnd); + mShadowNewEnd = newEnd + Math.max(0, mShadowNewEnd - oldEnd); + } + + public void shadowReplace(final int start, final int end, + final CharSequence newText) + { + if (DEBUG) { + assertOnIcThread(); + } + mShadowText.replace(start, end, newText); + addShadowChange(start, end, start + newText.length()); + } + + public void shadowSetSpan(final Object obj, final int start, + final int end, final int flags) { + if (DEBUG) { + assertOnIcThread(); + } + mShadowText.setSpan(obj, start, end, flags); + addShadowChange(start, end, end); + } + + public void shadowRemoveSpan(final Object obj) { + if (DEBUG) { + assertOnIcThread(); + } + if (obj == null) { + mShadowText.clearSpans(); + addShadowChange(0, mShadowText.length(), mShadowText.length()); + return; + } + final int start = mShadowText.getSpanStart(obj); + final int end = mShadowText.getSpanEnd(obj); + if (start < 0 || end < 0) { + return; + } + mShadowText.removeSpan(obj); + addShadowChange(start, end, end); + } + + // Return Spanned instead of Editable because the returned object is supposed to + // be read-only. Editing should be done through one of the shadow*** methods. + public Spanned getShadowText() { + if (DEBUG) { + assertOnIcThread(); + } + return mShadowText; + } + + public synchronized void syncShadowText(final GeckoEditableListener listener) { + if (DEBUG) { + assertOnIcThread(); + } + + if (mCurrentStart > mCurrentOldEnd && mShadowStart > mShadowOldEnd) { + // Still check selection changes. + if (!mCurrentSelectionChanged) { + return; + } + final int start = Selection.getSelectionStart(mCurrentText); + final int end = Selection.getSelectionEnd(mCurrentText); + Selection.setSelection(mShadowText, start, end); + mCurrentSelectionChanged = false; + + if (listener != null) { + listener.onSelectionChange(); + } + return; + } + + // Copy the portion of the current text that has changed over to the shadow + // text, with consideration for any concurrent changes in the shadow text. + final int start = Math.min(mShadowStart, mCurrentStart); + final int shadowEnd = mShadowNewEnd + Math.max(0, mCurrentOldEnd - mShadowOldEnd); + final int currentEnd = mCurrentNewEnd + Math.max(0, mShadowOldEnd - mCurrentOldEnd); + + // Perform replacement in two steps (delete and insert) so that old spans are + // properly deleted before identical new spans are inserted. Otherwise the new + // spans won't be inserted due to the text already having the old spans. + mShadowText.delete(start, shadowEnd); + mShadowText.insert(start, mCurrentText, start, currentEnd); + + // SpannableStringBuilder has some internal logic to fix up selections, but we + // don't want that, so we always fix up the selection a second time. + final int selStart = Selection.getSelectionStart(mCurrentText); + final int selEnd = Selection.getSelectionEnd(mCurrentText); + Selection.setSelection(mShadowText, selStart, selEnd); + + if (DEBUG && !mShadowText.equals(mCurrentText)) { + // Sanity check. + throw new IllegalStateException("Failed to sync: " + + mShadowStart + '-' + mShadowOldEnd + '-' + mShadowNewEnd + '/' + + mCurrentStart + '-' + mCurrentOldEnd + '-' + mCurrentNewEnd); + } + + if (listener != null) { + // Call onTextChange after selection fix-up but before we call + // onSelectionChange. + listener.onTextChange(); + + if (mCurrentSelectionChanged || (mCurrentOldEnd != mCurrentNewEnd && + (selStart >= mCurrentStart || selEnd >= mCurrentStart))) { + listener.onSelectionChange(); + } + } + + // These values ensure the first change is properly added. + mCurrentStart = mShadowStart = Integer.MAX_VALUE; + mCurrentOldEnd = mShadowOldEnd = 0; + mCurrentNewEnd = mShadowNewEnd = 0; + mCurrentSelectionChanged = false; + } + } + + /* An action that alters the Editable + + Each action corresponds to a Gecko event. While the Gecko event is being sent to the Gecko + thread, the action stays on top of mActions queue. After the Gecko event is processed and + replied, the action is removed from the queue + */ + private static final class Action { + // For input events (keypress, etc.); use with onImeSynchronize + static final int TYPE_EVENT = 0; + // For Editable.replace() call; use with onImeReplaceText + static final int TYPE_REPLACE_TEXT = 1; + // For Editable.setSpan() call; use with onImeSynchronize + static final int TYPE_SET_SPAN = 2; + // For Editable.removeSpan() call; use with onImeSynchronize + static final int TYPE_REMOVE_SPAN = 3; + // For switching handler; use with onImeSynchronize + static final int TYPE_SET_HANDLER = 4; + + final int mType; + int mStart; + int mEnd; + CharSequence mSequence; + Object mSpanObject; + int mSpanFlags; + Handler mHandler; + + Action(int type) { + mType = type; + } + + static Action newReplaceText(CharSequence text, int start, int end) { + if (start < 0 || start > end) { + Log.e(LOGTAG, "invalid replace text offsets: " + start + " to " + end); + throw new IllegalArgumentException("invalid replace text offsets"); + } + + final Action action = new Action(TYPE_REPLACE_TEXT); + action.mSequence = text; + action.mStart = start; + action.mEnd = end; + return action; + } + + static Action newSetSpan(Object object, int start, int end, int flags) { + if (start < 0 || start > end) { + Log.e(LOGTAG, "invalid span offsets: " + start + " to " + end); + throw new IllegalArgumentException("invalid span offsets"); + } + final Action action = new Action(TYPE_SET_SPAN); + action.mSpanObject = object; + action.mStart = start; + action.mEnd = end; + action.mSpanFlags = flags; + return action; + } + + static Action newRemoveSpan(Object object) { + final Action action = new Action(TYPE_REMOVE_SPAN); + action.mSpanObject = object; + return action; + } + + static Action newSetHandler(Handler handler) { + final Action action = new Action(TYPE_SET_HANDLER); + action.mHandler = handler; + return action; + } + } + + private void icOfferAction(final Action action) { + if (DEBUG) { + assertOnIcThread(); + Log.d(LOGTAG, "offer: Action(" + + getConstantName(Action.class, "TYPE_", action.mType) + ")"); + } + + if (mListener == null) { + // We haven't initialized or we've been destroyed. + return; + } + + mActions.offer(action); + + switch (action.mType) { + case Action.TYPE_EVENT: + case Action.TYPE_SET_HANDLER: + onImeSynchronize(); + break; + + case Action.TYPE_SET_SPAN: + mText.shadowSetSpan(action.mSpanObject, action.mStart, + action.mEnd, action.mSpanFlags); + action.mSequence = TextUtils.substring( + mText.getShadowText(), action.mStart, action.mEnd); + + mNeedUpdateComposition |= (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0 && + ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0 || + action.mSpanObject == Selection.SELECTION_START || + action.mSpanObject == Selection.SELECTION_END); + + onImeSynchronize(); + break; + + case Action.TYPE_REMOVE_SPAN: + final int flags = mText.getShadowText().getSpanFlags(action.mSpanObject); + mText.shadowRemoveSpan(action.mSpanObject); + + mNeedUpdateComposition |= (flags & Spanned.SPAN_INTERMEDIATE) == 0 && + (flags & Spanned.SPAN_COMPOSING) != 0; + + onImeSynchronize(); + break; + + case Action.TYPE_REPLACE_TEXT: + // Always sync text after a replace action, so that if the Gecko + // text is not changed, we will revert the shadow text to before. + mNeedSync = true; + + // Because we get composition styling here essentially for free, + // we don't need to check if we're in batch mode. + if (!icMaybeSendComposition( + action.mSequence, /* useEntireText */ true, /* notifyGecko */ false)) { + // Since we don't have a composition, we can try sending key events. + sendCharKeyEvents(action); + } + mText.shadowReplace(action.mStart, action.mEnd, action.mSequence); + onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString()); + break; + + default: + throw new IllegalStateException("Action not processed"); + } + } + + private KeyEvent [] synthesizeKeyEvents(CharSequence cs) { + try { + if (mKeyMap == null) { + mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + } catch (Exception e) { + // KeyCharacterMap.UnavailableException is not found on Gingerbread; + // besides, it seems like HC and ICS will throw something other than + // KeyCharacterMap.UnavailableException; so use a generic Exception here + return null; + } + KeyEvent [] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray()); + if (keyEvents == null || keyEvents.length == 0) { + return null; + } + return keyEvents; + } + + private void sendCharKeyEvents(Action action) { + if (action.mSequence.length() != 1 || + (action.mSequence instanceof Spannable && + ((Spannable)action.mSequence).nextSpanTransition( + -1, Integer.MAX_VALUE, null) < Integer.MAX_VALUE)) { + // Spans are not preserved when we use key events, + // so we need the sequence to not have any spans + return; + } + KeyEvent [] keyEvents = synthesizeKeyEvents(action.mSequence); + if (keyEvents == null) { + return; + } + for (KeyEvent event : keyEvents) { + if (KeyEvent.isModifierKey(event.getKeyCode())) { + continue; + } + if (event.getAction() == KeyEvent.ACTION_UP && mSuppressKeyUp) { + continue; + } + if (DEBUG) { + Log.d(LOGTAG, "sending: " + event); + } + onKeyEvent(event, event.getAction(), + /* metaState */ 0, /* isSynthesizedImeKey */ true); + } + } + + @WrapForJNI(calledFrom = "gecko") + GeckoEditable(final GeckoView v) { + if (DEBUG) { + // Called by nsWindow. + ThreadUtils.assertOnGeckoThread(); + } + + mText = new AsyncText(); + mActions = new ConcurrentLinkedQueue<Action>(); + + final Class<?>[] PROXY_INTERFACES = { Editable.class }; + mProxy = (Editable)Proxy.newProxyInstance( + Editable.class.getClassLoader(), + PROXY_INTERFACES, this); + + mIcRunHandler = mIcPostHandler = ThreadUtils.getUiHandler(); + + onViewChange(v); + } + + @WrapForJNI(dispatchTo = "proxy") @Override + protected native void disposeNative(); + + @WrapForJNI(calledFrom = "gecko") + private void onViewChange(final GeckoView v) { + if (DEBUG) { + // Called by nsWindow. + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "onViewChange(" + v + ")"); + } + + final GeckoEditableListener newListener = + v != null ? GeckoInputConnection.create(v, this) : null; + + final Runnable setListenerRunnable = new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "onViewChange (set listener)"); + } + + mListener = newListener; + + if (newListener == null) { + // We're being destroyed. By this point, we should have cleared all + // pending Runnables on the IC thread, so it's safe to call + // disposeNative here. + GeckoEditable.this.disposeNative(); + } + } + }; + + // Post to UI thread first to make sure any code that is using the old input + // connection has finished running, before we switch to a new input connection or + // before we clear the input connection on destruction. + final Handler icHandler = mIcPostHandler; + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "onViewChange (set IC)"); + } + + if (mView != null) { + // Detach the previous view. + mView.setInputConnectionListener(null); + } + if (v != null) { + // And attach the new view. + v.setInputConnectionListener((InputConnectionListener) newListener); + } + + mView = v; + icHandler.post(setListenerRunnable); + } + }); + } + + private boolean onIcThread() { + return mIcRunHandler.getLooper() == Looper.myLooper(); + } + + private void assertOnIcThread() { + ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW); + } + + private void geckoPostToIc(Runnable runnable) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + mIcPostHandler.post(runnable); + } + + private Object getField(Object obj, String field, Object def) { + try { + return obj.getClass().getField(field).get(obj); + } catch (Exception e) { + return def; + } + } + + /** + * Send composition ranges to Gecko for the entire shadow text. + */ + private void icMaybeSendComposition() { + if (!mNeedUpdateComposition) { + return; + } + + icMaybeSendComposition(mText.getShadowText(), + /* useEntireText */ false, /* notifyGecko */ true); + } + + /** + * Send composition ranges to Gecko if the text has composing spans. + * + * @param sequence Text with possible composing spans + * @param useEntireText If text has composing spans, treat the entire text as + * a Gecko composition, instead of just the spanned part. + * @param notifyGecko Notify Gecko of the new composition ranges; + * otherwise, the caller is responsible for notifying Gecko. + * @return Whether there was a composition + */ + private boolean icMaybeSendComposition(final CharSequence sequence, + final boolean useEntireText, + final boolean notifyGecko) { + mNeedUpdateComposition = false; + + int selStart = Selection.getSelectionStart(sequence); + int selEnd = Selection.getSelectionEnd(sequence); + + if (sequence instanceof Spanned) { + final Spanned text = (Spanned) sequence; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + boolean found = false; + int composingStart = useEntireText ? 0 : Integer.MAX_VALUE; + int composingEnd = useEntireText ? text.length() : 0; + + // Find existence and range of any composing spans (spans with the + // SPAN_COMPOSING flag set). + for (Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) == 0) { + continue; + } + found = true; + if (useEntireText) { + break; + } + composingStart = Math.min(composingStart, text.getSpanStart(span)); + composingEnd = Math.max(composingEnd, text.getSpanEnd(span)); + } + + if (useEntireText && (selStart < 0 || selEnd < 0)) { + selStart = composingEnd; + selEnd = composingEnd; + } + + if (found) { + icSendComposition(text, selStart, selEnd, composingStart, composingEnd); + if (notifyGecko) { + onImeUpdateComposition(composingStart, composingEnd); + } + return true; + } + } + + if (notifyGecko) { + // Set the selection by using a composition without ranges + onImeUpdateComposition(selStart, selEnd); + } + + if (DEBUG) { + Log.d(LOGTAG, "icSendComposition(): no composition"); + } + return false; + } + + private void icSendComposition(final Spanned text, + final int selStart, final int selEnd, + final int composingStart, final int composingEnd) { + if (DEBUG) { + assertOnIcThread(); + Log.d(LOGTAG, "icSendComposition(\"" + text + "\", " + + composingStart + ", " + composingEnd + ")"); + } + if (DEBUG) { + Log.d(LOGTAG, " range = " + composingStart + "-" + composingEnd); + Log.d(LOGTAG, " selection = " + selStart + "-" + selEnd); + } + + if (selEnd >= composingStart && selEnd <= composingEnd) { + onImeAddCompositionRange( + selEnd - composingStart, selEnd - composingStart, + IME_RANGE_CARETPOSITION, 0, 0, false, 0, 0, 0); + } + + int rangeStart = composingStart; + TextPaint tp = new TextPaint(); + TextPaint emptyTp = new TextPaint(); + // set initial foreground color to 0, because we check for tp.getColor() == 0 + // below to decide whether to pass a foreground color to Gecko + emptyTp.setColor(0); + do { + int rangeType, rangeStyles = 0, rangeLineStyle = IME_RANGE_LINE_NONE; + boolean rangeBoldLine = false; + int rangeForeColor = 0, rangeBackColor = 0, rangeLineColor = 0; + int rangeEnd = text.nextSpanTransition(rangeStart, composingEnd, Object.class); + + if (selStart > rangeStart && selStart < rangeEnd) { + rangeEnd = selStart; + } else if (selEnd > rangeStart && selEnd < rangeEnd) { + rangeEnd = selEnd; + } + CharacterStyle[] styleSpans = + text.getSpans(rangeStart, rangeEnd, CharacterStyle.class); + + if (DEBUG) { + Log.d(LOGTAG, " found " + styleSpans.length + " spans @ " + + rangeStart + "-" + rangeEnd); + } + + if (styleSpans.length == 0) { + rangeType = (selStart == rangeStart && selEnd == rangeEnd) + ? IME_RANGE_SELECTEDRAWTEXT + : IME_RANGE_RAWINPUT; + } else { + rangeType = (selStart == rangeStart && selEnd == rangeEnd) + ? IME_RANGE_SELECTEDCONVERTEDTEXT + : IME_RANGE_CONVERTEDTEXT; + tp.set(emptyTp); + for (CharacterStyle span : styleSpans) { + span.updateDrawState(tp); + } + int tpUnderlineColor = 0; + float tpUnderlineThickness = 0.0f; + + // These TextPaint fields only exist on Android ICS+ and are not in the SDK. + tpUnderlineColor = (Integer)getField(tp, "underlineColor", 0); + tpUnderlineThickness = (Float)getField(tp, "underlineThickness", 0.0f); + if (tpUnderlineColor != 0) { + rangeStyles |= IME_RANGE_UNDERLINE | IME_RANGE_LINECOLOR; + rangeLineColor = tpUnderlineColor; + // Approximately translate underline thickness to what Gecko understands + if (tpUnderlineThickness <= 0.5f) { + rangeLineStyle = IME_RANGE_LINE_DOTTED; + } else { + rangeLineStyle = IME_RANGE_LINE_SOLID; + if (tpUnderlineThickness >= 2.0f) { + rangeBoldLine = true; + } + } + } else if (tp.isUnderlineText()) { + rangeStyles |= IME_RANGE_UNDERLINE; + rangeLineStyle = IME_RANGE_LINE_SOLID; + } + if (tp.getColor() != 0) { + rangeStyles |= IME_RANGE_FORECOLOR; + rangeForeColor = tp.getColor(); + } + if (tp.bgColor != 0) { + rangeStyles |= IME_RANGE_BACKCOLOR; + rangeBackColor = tp.bgColor; + } + } + onImeAddCompositionRange( + rangeStart - composingStart, rangeEnd - composingStart, + rangeType, rangeStyles, rangeLineStyle, rangeBoldLine, + rangeForeColor, rangeBackColor, rangeLineColor); + rangeStart = rangeEnd; + + if (DEBUG) { + Log.d(LOGTAG, " added " + rangeType + + " : " + Integer.toHexString(rangeStyles) + + " : " + Integer.toHexString(rangeForeColor) + + " : " + Integer.toHexString(rangeBackColor)); + } + } while (rangeStart < composingEnd); + } + + // GeckoEditableClient interface + + @Override + public void sendKeyEvent(final KeyEvent event, int action, int metaState) { + if (DEBUG) { + assertOnIcThread(); + Log.d(LOGTAG, "sendKeyEvent(" + event + ", " + action + ", " + metaState + ")"); + } + /* + We are actually sending two events to Gecko here, + 1. Event from the event parameter (key event) + 2. Sync event from the icOfferAction call + The first event is a normal event that does not reply back to us, + the second sync event will have a reply, during which we see that there is a pending + event-type action, and update the shadow text accordingly. + */ + icMaybeSendComposition(); + onKeyEvent(event, action, metaState, /* isSynthesizedImeKey */ false); + icOfferAction(new Action(Action.TYPE_EVENT)); + } + + @Override + public Editable getEditable() { + if (!onIcThread()) { + // Android may be holding an old InputConnection; ignore + if (DEBUG) { + Log.i(LOGTAG, "getEditable() called on non-IC thread"); + } + return null; + } + if (mListener == null) { + // We haven't initialized or we've been destroyed. + return null; + } + return mProxy; + } + + @Override + public void setBatchMode(boolean inBatchMode) { + if (!onIcThread()) { + // Android may be holding an old InputConnection; ignore + if (DEBUG) { + Log.i(LOGTAG, "setBatchMode() called on non-IC thread"); + } + return; + } + + mInBatchMode = inBatchMode; + + if (!inBatchMode && mNeedSync) { + icSyncShadowText(); + } + } + + /* package */ void icSyncShadowText() { + if (mListener == null) { + // Not yet attached or already destroyed. + return; + } + + if (mInBatchMode || !mActions.isEmpty()) { + mNeedSync = true; + return; + } + + mNeedSync = false; + mText.syncShadowText(mListener); + } + + private void geckoScheduleSyncShadowText() { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + } + geckoPostToIc(new Runnable() { + @Override + public void run() { + icSyncShadowText(); + } + }); + } + + @Override + public void setSuppressKeyUp(boolean suppress) { + if (DEBUG) { + assertOnIcThread(); + } + // Suppress key up event generated as a result of + // translating characters to key events + mSuppressKeyUp = suppress; + } + + @Override // GeckoEditableClient + public Handler setInputConnectionHandler(final Handler handler) { + if (handler == mIcRunHandler) { + return mIcRunHandler; + } + if (DEBUG) { + assertOnIcThread(); + } + + // There are three threads at this point: Gecko thread, old IC thread, and new IC + // thread, and we want to safely switch from old IC thread to new IC thread. + // We first send a TYPE_SET_HANDLER action to the Gecko thread; this ensures that + // the Gecko thread is stopped at a known point. At the same time, the old IC + // thread blocks on the action; this ensures that the old IC thread is stopped at + // a known point. Finally, inside the Gecko thread, we post a Runnable to the old + // IC thread; this Runnable switches from old IC thread to new IC thread. We + // switch IC thread on the old IC thread to ensure any pending Runnables on the + // old IC thread are processed before we switch over. Inside the Gecko thread, we + // also post a Runnable to the new IC thread; this Runnable blocks until the + // switch is complete; this ensures that the new IC thread won't accept + // InputConnection calls until after the switch. + + handler.post(new Runnable() { // Make the new IC thread wait. + @Override + public void run() { + synchronized (handler) { + while (mIcRunHandler != handler) { + try { + handler.wait(); + } catch (final InterruptedException e) { + } + } + } + } + }); + + icOfferAction(Action.newSetHandler(handler)); + return handler; + } + + @Override // GeckoEditableClient + public void postToInputConnection(final Runnable runnable) { + mIcPostHandler.post(runnable); + } + + @Override // GeckoEditableClient + public void requestCursorUpdates(int requestMode) { + onImeRequestCursorUpdates(requestMode); + } + + private void geckoSetIcHandler(final Handler newHandler) { + geckoPostToIc(new Runnable() { // posting to old IC thread + @Override + public void run() { + synchronized (newHandler) { + mIcRunHandler = newHandler; + newHandler.notify(); + } + } + }); + + // At this point, all future Runnables should be posted to the new IC thread, but + // we don't switch mIcRunHandler yet because there may be pending Runnables on the + // old IC thread still waiting to run. + mIcPostHandler = newHandler; + } + + private void geckoActionReply(final Action action) { + if (!mGeckoFocused) { + if (DEBUG) { + Log.d(LOGTAG, "discarding stale reply"); + } + return; + } + + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "reply: Action(" + + getConstantName(Action.class, "TYPE_", action.mType) + ")"); + } + switch (action.mType) { + case Action.TYPE_SET_SPAN: + final int len = mText.getCurrentText().length(); + if (action.mStart > len || action.mEnd > len || + !TextUtils.substring(mText.getCurrentText(), action.mStart, + action.mEnd).equals(action.mSequence)) { + if (DEBUG) { + Log.d(LOGTAG, "discarding stale set span call"); + } + break; + } + mText.currentSetSpan(action.mSpanObject, action.mStart, action.mEnd, action.mSpanFlags); + break; + + case Action.TYPE_REMOVE_SPAN: + mText.currentRemoveSpan(action.mSpanObject); + break; + + case Action.TYPE_SET_HANDLER: + geckoSetIcHandler(action.mHandler); + break; + } + } + + private void notifyCommitComposition() { + // Gecko already committed its composition. However, Android keyboards + // have trouble dealing with us removing the composition manually on + // the Java side. Therefore, we keep the composition intact on the Java + // side. The text content should still be in-sync on both sides. + } + + private void notifyCancelComposition() { + // Composition should have been canceled on our side + // through text update notifications; verify that here. + if (DEBUG) { + final Spanned text = mText.getCurrentText(); + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + throw new IllegalStateException("composition not cancelled"); + } + } + } + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIME(final int type) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply() + if (type != GeckoEditableListener.NOTIFY_IME_REPLY_EVENT) { + Log.d(LOGTAG, "notifyIME(" + + getConstantName(GeckoEditableListener.class, "NOTIFY_IME_", type) + + ")"); + } + } + + if (type == GeckoEditableListener.NOTIFY_IME_REPLY_EVENT) { + geckoActionReply(mActions.poll()); + if (!mGeckoFocused || !mActions.isEmpty()) { + // Only post to IC thread below when the queue is empty. + return; + } + } else if (type == GeckoEditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION) { + notifyCommitComposition(); + return; + } else if (type == GeckoEditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION) { + notifyCancelComposition(); + return; + } + + geckoPostToIc(new Runnable() { + @Override + public void run() { + if (type == GeckoEditableListener.NOTIFY_IME_REPLY_EVENT) { + if (mNeedSync) { + icSyncShadowText(); + } + return; + } + + if (type == GeckoEditableListener.NOTIFY_IME_OF_FOCUS && mListener != null) { + mNeedSync = false; + mText.syncShadowText(/* listener */ null); + } + + if (mListener != null) { + mListener.notifyIME(type); + } + } + }); + + // Update the mGeckoFocused flag. + if (type == GeckoEditableListener.NOTIFY_IME_OF_BLUR) { + mGeckoFocused = false; + } else if (type == GeckoEditableListener.NOTIFY_IME_OF_FOCUS) { + mGeckoFocused = true; + } + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIMEContext(final int state, final String typeHint, + final String modeHint, final String actionHint) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "notifyIMEContext(" + + getConstantName(GeckoEditableListener.class, "IME_STATE_", state) + + ", \"" + typeHint + "\", \"" + modeHint + "\", \"" + actionHint + "\")"); + } + geckoPostToIc(new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.notifyIMEContext(state, typeHint, modeHint, actionHint); + } + }); + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onSelectionChange(final int start, final int end) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "onSelectionChange(" + start + ", " + end + ")"); + } + + final int currentLength = mText.getCurrentText().length(); + if (start < 0 || start > currentLength || end < 0 || end > currentLength) { + Log.e(LOGTAG, "invalid selection notification range: " + + start + " to " + end + ", length: " + currentLength); + throw new IllegalArgumentException("invalid selection notification range"); + } + + if (mIgnoreSelectionChange) { + mIgnoreSelectionChange = false; + } else { + mText.currentSetSelection(start, end); + } + + geckoScheduleSyncShadowText(); + } + + private boolean geckoIsSameText(int start, int oldEnd, CharSequence newText) { + return oldEnd - start == newText.length() && + TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start); + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onTextChange(final CharSequence text, final int start, + final int unboundedOldEnd, final int unboundedNewEnd) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + StringBuilder sb = new StringBuilder("onTextChange("); + debugAppend(sb, text); + sb.append(", ").append(start).append(", ") + .append(unboundedOldEnd).append(", ") + .append(unboundedNewEnd).append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (start < 0 || start > unboundedOldEnd) { + Log.e(LOGTAG, "invalid text notification range: " + + start + " to " + unboundedOldEnd); + throw new IllegalArgumentException("invalid text notification range"); + } + + final int currentLength = mText.getCurrentText().length(); + + /* For the "end" parameters, Gecko can pass in a large + number to denote "end of the text". Fix that here */ + final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd; + // new end should always match text + if (unboundedOldEnd <= currentLength && unboundedNewEnd != (start + text.length())) { + Log.e(LOGTAG, "newEnd does not match text: " + unboundedNewEnd + " vs " + + (start + text.length())); + throw new IllegalArgumentException("newEnd does not match text"); + } + + final int newEnd = start + text.length(); + final Action action = mActions.peek(); + + if (start == 0 && unboundedOldEnd > currentLength) { + // Simply replace the text for newly-focused editors. Replace in two steps to + // properly clear composing spans that span the whole range. + mText.currentReplace(0, currentLength, ""); + mText.currentReplace(0, 0, text); + + // Don't ignore the next selection change because we are re-syncing with Gecko + mIgnoreSelectionChange = false; + + } else if (action != null && + action.mType == Action.TYPE_REPLACE_TEXT && + start <= action.mStart && + oldEnd >= action.mEnd && + newEnd >= action.mStart + action.mSequence.length()) { + + // Try to preserve both old spans and new spans in action.mSequence. + // indexInText is where we can find waction.mSequence within the passed in text. + final int startWithinText = action.mStart - start; + int indexInText = TextUtils.indexOf(text, action.mSequence, startWithinText); + if (indexInText < 0 && startWithinText >= action.mSequence.length()) { + indexInText = text.toString().lastIndexOf(action.mSequence.toString(), + startWithinText); + } + + if (indexInText < 0) { + // Text was changed from under us. We are forced to discard any new spans. + mText.currentReplace(start, oldEnd, text); + + // Don't ignore the next selection change because we are forced to re-sync + // with Gecko here. + mIgnoreSelectionChange = false; + + } else if (indexInText == 0 && text.length() == action.mSequence.length() && + oldEnd - start == action.mEnd - action.mStart) { + // The new change exactly matches our saved change, so do a direct replace. + mText.currentReplace(start, oldEnd, action.mSequence); + + // Ignore the next selection change because the selection change is a + // side-effect of the replace-text event we sent. + mIgnoreSelectionChange = true; + + } else { + // The sequence is embedded within the changed text, so we have to perform + // replacement in parts. First replace part of text before the sequence. + mText.currentReplace(start, action.mStart, text.subSequence(0, indexInText)); + + // Then replace part of the text after the sequence. + final int actionStart = indexInText + start; + final int delta = actionStart - action.mStart; + final int actionEnd = delta + action.mEnd; + + final Spanned currentText = mText.getCurrentText(); + final boolean resetSelStart = Selection.getSelectionStart(currentText) == actionEnd; + final boolean resetSelEnd = Selection.getSelectionEnd(currentText) == actionEnd; + + mText.currentReplace(actionEnd, delta + oldEnd, text.subSequence( + indexInText + action.mSequence.length(), text.length())); + + // The replacement above may have shifted our selection, if the selection + // was at the start of the replacement range. If so, we need to reset + // our selection to the previous position. + if (resetSelStart || resetSelEnd) { + mText.currentSetSelection( + resetSelStart ? actionEnd : Selection.getSelectionStart(currentText), + resetSelEnd ? actionEnd : Selection.getSelectionEnd(currentText)); + } + + // Finally replace the sequence itself to preserve new spans. + mText.currentReplace(actionStart, actionEnd, action.mSequence); + + // Ignore the next selection change because the selection change is a + // side-effect of the replace-text event we sent. + mIgnoreSelectionChange = true; + } + + } else if (geckoIsSameText(start, oldEnd, text)) { + // Nothing to do because the text is the same. This could happen when + // the composition is updated for example, in which case we want to keep the + // Java selection. + mIgnoreSelectionChange = mIgnoreSelectionChange || + (action != null && action.mType == Action.TYPE_REPLACE_TEXT); + return; + + } else { + // Gecko side initiated the text change. Replace in two steps to properly + // clear composing spans that span the whole range. + mText.currentReplace(start, oldEnd, ""); + mText.currentReplace(start, start, text); + + // Don't ignore the next selection change because we are forced to re-sync + // with Gecko here. + mIgnoreSelectionChange = false; + } + + // onTextChange is always followed by onSelectionChange, so we let + // onSelectionChange schedule a shadow text sync. + } + + @WrapForJNI(calledFrom = "gecko") + private void onDefaultKeyEvent(final KeyEvent event) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + StringBuilder sb = new StringBuilder("onDefaultKeyEvent("); + sb.append("action=").append(event.getAction()).append(", ") + .append("keyCode=").append(event.getKeyCode()).append(", ") + .append("metaState=").append(event.getMetaState()).append(", ") + .append("time=").append(event.getEventTime()).append(", ") + .append("repeatCount=").append(event.getRepeatCount()).append(")"); + Log.d(LOGTAG, sb.toString()); + } + + geckoPostToIc(new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.onDefaultKeyEvent(event); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private void updateCompositionRects(final RectF[] aRects) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "updateCompositionRects(aRects.length = " + aRects.length + ")"); + } + geckoPostToIc(new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.updateCompositionRects(aRects); + } + }); + } + + // InvocationHandler interface + + static String getConstantName(Class<?> cls, String prefix, Object value) { + for (Field fld : cls.getDeclaredFields()) { + try { + if (fld.getName().startsWith(prefix) && + fld.get(null).equals(value)) { + return fld.getName(); + } + } catch (IllegalAccessException e) { + } + } + return String.valueOf(value); + } + + static StringBuilder debugAppend(StringBuilder sb, Object obj) { + if (obj == null) { + sb.append("null"); + } else if (obj instanceof GeckoEditable) { + sb.append("GeckoEditable"); + } else if (Proxy.isProxyClass(obj.getClass())) { + debugAppend(sb, Proxy.getInvocationHandler(obj)); + } else if (obj instanceof CharSequence) { + sb.append('"').append(obj.toString().replace('\n', '\u21b2')).append('"'); + } else if (obj.getClass().isArray()) { + sb.append(obj.getClass().getComponentType().getSimpleName()).append('[') + .append(Array.getLength(obj)).append(']'); + } else { + sb.append(obj); + } + return sb; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + Object target; + final Class<?> methodInterface = method.getDeclaringClass(); + if (DEBUG) { + // Editable methods should all be called from the IC thread + assertOnIcThread(); + } + if (methodInterface == Editable.class || + methodInterface == Appendable.class || + methodInterface == Spannable.class) { + // Method alters the Editable; route calls to our implementation + target = this; + } else { + target = mText.getShadowText(); + } + Object ret; + try { + ret = method.invoke(target, args); + } catch (InvocationTargetException e) { + // Bug 817386 + // Most likely Gecko has changed the text while GeckoInputConnection is + // trying to access the text. If we pass through the exception here, Fennec + // will crash due to a lack of exception handler. Log the exception and + // return an empty value instead. + if (!(e.getCause() instanceof IndexOutOfBoundsException)) { + // Only handle IndexOutOfBoundsException for now, + // as other exceptions might signal other bugs + throw e; + } + Log.w(LOGTAG, "Exception in GeckoEditable." + method.getName(), e.getCause()); + Class<?> retClass = method.getReturnType(); + if (retClass == Character.TYPE) { + ret = '\0'; + } else if (retClass == Integer.TYPE) { + ret = 0; + } else if (retClass == String.class) { + ret = ""; + } else { + ret = null; + } + } + if (DEBUG) { + StringBuilder log = new StringBuilder(method.getName()); + log.append("("); + if (args != null) { + for (Object arg : args) { + debugAppend(log, arg).append(", "); + } + if (args.length > 0) { + log.setLength(log.length() - 2); + } + } + if (method.getReturnType().equals(Void.TYPE)) { + log.append(")"); + } else { + debugAppend(log.append(") = "), ret); + } + Log.d(LOGTAG, log.toString()); + } + return ret; + } + + // Spannable interface + + @Override + public void removeSpan(Object what) { + if (what == null) { + return; + } + + if (what == Selection.SELECTION_START || + what == Selection.SELECTION_END) { + Log.w(LOGTAG, "selection removed with removeSpan()"); + } + + icOfferAction(Action.newRemoveSpan(what)); + } + + @Override + public void setSpan(Object what, int start, int end, int flags) { + icOfferAction(Action.newSetSpan(what, start, end, flags)); + } + + // Appendable interface + + @Override + public Editable append(CharSequence text) { + return replace(mProxy.length(), mProxy.length(), text, 0, text.length()); + } + + @Override + public Editable append(CharSequence text, int start, int end) { + return replace(mProxy.length(), mProxy.length(), text, start, end); + } + + @Override + public Editable append(char text) { + return replace(mProxy.length(), mProxy.length(), String.valueOf(text), 0, 1); + } + + // Editable interface + + @Override + public InputFilter[] getFilters() { + return mFilters; + } + + @Override + public void setFilters(InputFilter[] filters) { + mFilters = filters; + } + + @Override + public void clearSpans() { + /* XXX this clears the selection spans too, + but there is no way to clear the corresponding selection in Gecko */ + Log.w(LOGTAG, "selection cleared with clearSpans()"); + icOfferAction(Action.newRemoveSpan(/* what */ null)); + } + + @Override + public Editable replace(int st, int en, + CharSequence source, int start, int end) { + + CharSequence text = source; + if (start < 0 || start > end || end > text.length()) { + Log.e(LOGTAG, "invalid replace offsets: " + + start + " to " + end + ", length: " + text.length()); + throw new IllegalArgumentException("invalid replace offsets"); + } + if (start != 0 || end != text.length()) { + text = text.subSequence(start, end); + } + if (mFilters != null) { + // Filter text before sending the request to Gecko + for (int i = 0; i < mFilters.length; ++i) { + final CharSequence cs = mFilters[i].filter( + text, 0, text.length(), mProxy, st, en); + if (cs != null) { + text = cs; + } + } + } + if (text == source) { + // Always create a copy + text = new SpannableString(source); + } + icOfferAction(Action.newReplaceText(text, Math.min(st, en), Math.max(st, en))); + return mProxy; + } + + @Override + public void clear() { + replace(0, mProxy.length(), "", 0, 0); + } + + @Override + public Editable delete(int st, int en) { + return replace(st, en, "", 0, 0); + } + + @Override + public Editable insert(int where, CharSequence text, + int start, int end) { + return replace(where, where, text, start, end); + } + + @Override + public Editable insert(int where, CharSequence text) { + return replace(where, where, text, 0, text.length()); + } + + @Override + public Editable replace(int st, int en, CharSequence text) { + return replace(st, en, text, 0, text.length()); + } + + /* GetChars interface */ + + @Override + public void getChars(int start, int end, char[] dest, int destoff) { + /* overridden Editable interface methods in GeckoEditable must not be called directly + outside of GeckoEditable. Instead, the call must go through mProxy, which ensures + that Java is properly synchronized with Gecko */ + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + /* Spanned interface */ + + @Override + public int getSpanEnd(Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanFlags(Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanStart(Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public <T> T[] getSpans(int start, int end, Class<T> type) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + @SuppressWarnings("rawtypes") // nextSpanTransition uses raw Class in its Android declaration + public int nextSpanTransition(int start, int limit, Class type) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + /* CharSequence interface */ + + @Override + public char charAt(int index) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int length() { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public CharSequence subSequence(int start, int end) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public String toString() { + throw new UnsupportedOperationException("method must be called through mProxy"); + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableClient.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableClient.java new file mode 100644 index 000000000..5e721b3af --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableClient.java @@ -0,0 +1,33 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.os.Handler; +import android.text.Editable; +import android.view.KeyEvent; + +/** + * Interface for the IC thread. + */ +interface GeckoEditableClient { + void sendKeyEvent(KeyEvent event, int action, int metaState); + Editable getEditable(); + void setBatchMode(boolean isBatchMode); + void setSuppressKeyUp(boolean suppress); + Handler setInputConnectionHandler(Handler handler); + void postToInputConnection(Runnable runnable); + + // The following value is used by requestCursorUpdates + + // ONE_SHOT calls updateCompositionRects() after getting current composing character rects. + public static final int ONE_SHOT = 1; + // START_MONITOR start the monitor for composing character rects. If is is updaed, call updateCompositionRects() + public static final int START_MONITOR = 2; + // ENDT_MONITOR stops the monitor for composing character rects. + public static final int END_MONITOR = 3; + + void requestCursorUpdates(int requestMode); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableListener.java new file mode 100644 index 000000000..db594aaf7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableListener.java @@ -0,0 +1,43 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.WrapForJNI; + +import android.graphics.RectF; +import android.view.KeyEvent; + +/** + * Interface for the Editable to listen on the Gecko thread, as well as for the IC thread to listen + * to the Editable. + */ +interface GeckoEditableListener { + // IME notification type for notifyIME(), corresponding to NotificationToIME enum in Gecko + @WrapForJNI + int NOTIFY_IME_OPEN_VKB = -2; + @WrapForJNI + int NOTIFY_IME_REPLY_EVENT = -1; + @WrapForJNI + int NOTIFY_IME_OF_FOCUS = 1; + @WrapForJNI + int NOTIFY_IME_OF_BLUR = 2; + @WrapForJNI + int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8; + @WrapForJNI + int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9; + // IME enabled state for notifyIMEContext() + int IME_STATE_DISABLED = 0; + int IME_STATE_ENABLED = 1; + int IME_STATE_PASSWORD = 2; + int IME_STATE_PLUGIN = 3; + + void notifyIME(int type); + void notifyIMEContext(int state, String typeHint, String modeHint, String actionHint); + void onSelectionChange(); + void onTextChange(); + void onDefaultKeyEvent(KeyEvent event); + void updateCompositionRects(final RectF[] aRects); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java new file mode 100644 index 000000000..3d9b97427 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoHalDefines.java @@ -0,0 +1,27 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +public class GeckoHalDefines +{ + /* + * Keep these values consistent with |SensorType| in HalSensor.h + */ + public static final int SENSOR_ORIENTATION = 0; + public static final int SENSOR_ACCELERATION = 1; + public static final int SENSOR_PROXIMITY = 2; + public static final int SENSOR_LINEAR_ACCELERATION = 3; + public static final int SENSOR_GYROSCOPE = 4; + public static final int SENSOR_LIGHT = 5; + public static final int SENSOR_ROTATION_VECTOR = 6; + public static final int SENSOR_GAME_ROTATION_VECTOR = 7; + + public static final int SENSOR_ACCURACY_UNKNOWN = -1; + public static final int SENSOR_ACCURACY_UNRELIABLE = 0; + public static final int SENSOR_ACCURACY_LOW = 1; + public static final int SENSOR_ACCURACY_MED = 2; + public static final int SENSOR_ACCURACY_HIGH = 3; +}; diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoInputConnection.java new file mode 100644 index 000000000..a80be0bce --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoInputConnection.java @@ -0,0 +1,1060 @@ +/* -*- 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 java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.SynchronousQueue; + +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.gfx.DynamicToolbarAnimator; +import org.mozilla.gecko.util.Clipboard; +import org.mozilla.gecko.util.GamepadUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; + +import android.annotation.SuppressLint; +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.Handler; +import android.os.Looper; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.text.SpannableString; +import android.text.method.KeyListener; +import android.text.method.TextKeyListener; +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 android.view.inputmethod.InputMethodManager; + +class GeckoInputConnection + extends BaseInputConnection + implements InputConnectionListener, GeckoEditableListener { + + 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 mIMETypeHint = ""; + private String mIMEModeHint = ""; + private String mIMEActionHint = ""; + private boolean mFocused; + + private String mCurrentInputMethod = ""; + + private final View mView; + private final GeckoEditableClient mEditableClient; + protected int mBatchEditCount; + private ExtractedTextRequest mUpdateRequest; + private final ExtractedText mUpdateExtract = new ExtractedText(); + private final InputConnection mKeyInputConnection; + private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; + + // Prevent showSoftInput and hideSoftInput from causing reentrant calls on some devices. + private volatile boolean mSoftInputReentrancyGuard; + + public static GeckoEditableListener create(View targetView, + GeckoEditableClient editable) { + if (DEBUG) + return DebugGeckoInputConnection.create(targetView, editable); + else + return new GeckoInputConnection(targetView, editable); + } + + protected GeckoInputConnection(View targetView, + GeckoEditableClient editable) { + super(targetView, true); + 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) { + Editable editable = getEditable(); + if (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(editable); + editable.clear(); + } else { + Clipboard.setText( + editable.toString().substring( + Math.min(selStart, selEnd), + Math.max(selStart, selEnd))); + editable.delete(selStart, selEnd); + } + break; + case android.R.id.paste: + commitText(Clipboard.getText(), 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(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; + } + + private View getView() { + return mView; + } + + private InputMethodManager getInputMethodManager() { + View view = getView(); + if (view == null) { + return null; + } + Context context = view.getContext(); + return InputMethods.getInputMethodManager(context); + } + + private void showSoftInput() { + if (mSoftInputReentrancyGuard) { + return; + } + final View v = getView(); + final InputMethodManager imm = getInputMethodManager(); + if (v == null || imm == null) { + return; + } + + v.post(new Runnable() { + @Override + public void run() { + if (v.hasFocus() && !imm.isActive(v)) { + // Marshmallow workaround: The view has focus but it is not the active + // view for the input method. (Bug 1211848) + v.clearFocus(); + v.requestFocus(); + } + GeckoAppShell.getLayerView().getDynamicToolbarAnimator().showToolbar(/*immediately*/true); + mSoftInputReentrancyGuard = true; + imm.showSoftInput(v, 0); + mSoftInputReentrancyGuard = false; + } + }); + } + + private void hideSoftInput() { + if (mSoftInputReentrancyGuard) { + return; + } + final InputMethodManager imm = getInputMethodManager(); + if (imm != null) { + final View v = getView(); + mSoftInputReentrancyGuard = true; + imm.hideSoftInputFromWindow(v.getWindowToken(), 0); + mSoftInputReentrancyGuard = false; + } + } + + private void restartInput() { + + final InputMethodManager imm = getInputMethodManager(); + if (imm == null) { + return; + } + final View v = getView(); + // InputMethodManager has internal logic to detect if we are restarting input + // in an already focused View, which is the case here because all content text + // fields are inside one LayerView. When this happens, InputMethodManager will + // tell the input method to soft reset instead of hard reset. Stock latin IME + // on Android 4.2+ has a quirk that when it soft resets, it does not clear the + // composition. The following workaround tricks the IME into clearing the + // composition when soft resetting. + if (InputMethods.needsSoftResetWorkaround(mCurrentInputMethod)) { + // Fake a selection change, because the IME clears the composition when + // the selection changes, even if soft-resetting. Offsets here must be + // different from the previous selection offsets, and -1 seems to be a + // reasonable, deterministic value + notifySelectionChange(-1, -1); + } + try { + imm.restartInput(v); + } catch (RuntimeException e) { + Log.e(LOGTAG, "Error restarting input", e); + } + } + + private void resetInputConnection() { + if (mBatchEditCount != 0) { + Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); + mBatchEditCount = 0; + } + + // Do not reset mIMEState here; see comments in notifyIMEContext + + restartInput(); + } + + @Override // GeckoEditableListener + public void onTextChange() { + + if (mUpdateRequest == null) { + return; + } + + final InputMethodManager imm = getInputMethodManager(); + final View v = getView(); + final Editable editable = getEditable(); + if (imm == null || v == null || editable == null) { + return; + } + mUpdateExtract.flags = 0; + // Update the entire Editable range + mUpdateExtract.partialStartOffset = -1; + mUpdateExtract.partialEndOffset = -1; + mUpdateExtract.selectionStart = Selection.getSelectionStart(editable); + mUpdateExtract.selectionEnd = Selection.getSelectionEnd(editable); + mUpdateExtract.startOffset = 0; + if ((mUpdateRequest.flags & GET_TEXT_WITH_STYLES) != 0) { + mUpdateExtract.text = new SpannableString(editable); + } else { + mUpdateExtract.text = editable.toString(); + } + imm.updateExtractedText(v, mUpdateRequest.token, mUpdateExtract); + } + + @Override // GeckoEditableListener + public void onSelectionChange() { + + final Editable editable = getEditable(); + if (editable != null) { + notifySelectionChange(Selection.getSelectionStart(editable), + Selection.getSelectionEnd(editable)); + } + } + + private void notifySelectionChange(int start, int end) { + + final InputMethodManager imm = getInputMethodManager(); + final View v = getView(); + final Editable editable = getEditable(); + if (imm == null || v == null || editable == null) { + return; + } + imm.updateSelection(v, start, end, getComposingSpanStart(editable), + getComposingSpanEnd(editable)); + } + + @Override + public void updateCompositionRects(final RectF[] aRects) { + if (!Versions.feature21Plus) { + return; + } + + if (mCursorAnchorInfoBuilder == null) { + mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); + } + mCursorAnchorInfoBuilder.reset(); + + // Calculate Gecko logical coords to screen coords + final View v = getView(); + if (v == null) { + return; + } + + int[] viewCoords = new int[2]; + v.getLocationOnScreen(viewCoords); + + DynamicToolbarAnimator animator = GeckoAppShell.getLayerView().getDynamicToolbarAnimator(); + float toolbarHeight = animator.getMaxTranslation() - animator.getToolbarTranslation(); + + Matrix matrix = GeckoAppShell.getLayerView().getMatrixForLayerRectToViewRect(); + if (matrix == null) { + if (DEBUG) { + Log.d(LOGTAG, "Cannot get Matrix to convert from Gecko coords to layer view coords"); + } + return; + } + matrix.postTranslate(viewCoords[0], viewCoords[1] + toolbarHeight); + mCursorAnchorInfoBuilder.setMatrix(matrix); + + final Editable content = getEditable(); + if (content == null) { + return; + } + int composingStart = getComposingSpanStart(content); + int composingEnd = getComposingSpanEnd(content); + if (composingStart < 0 || composingEnd < 0) { + if (DEBUG) { + Log.d(LOGTAG, "No composition for updates"); + } + return; + } + + for (int i = 0; i < aRects.length; i++) { + mCursorAnchorInfoBuilder.addCharacterBounds(i, + aRects[i].left, + aRects[i].top, + aRects[i].right, + aRects[i].bottom, + CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); + } + + mCursorAnchorInfoBuilder.setComposingText(0, content.subSequence(composingStart, composingEnd)); + + updateCursor(); + } + + @TargetApi(21) + private void updateCursor() { + if (mCursorAnchorInfoBuilder == null) { + return; + } + + final InputMethodManager imm = getInputMethodManager(); + final View v = getView(); + if (imm == null || v == null) { + return; + } + + imm.updateCursorAnchorInfo(v, mCursorAnchorInfoBuilder.build()); + } + + @Override + public boolean requestCursorUpdates(int cursorUpdateMode) { + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) { + mEditableClient.requestCursorUpdates(GeckoEditableClient.ONE_SHOT); + } + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) { + mEditableClient.requestCursorUpdates(GeckoEditableClient.START_MONITOR); + } else { + mEditableClient.requestCursorUpdates(GeckoEditableClient.END_MONITOR); + } + return true; + } + + @Override + 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 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 + return true; + } + 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; + } + + // Android N: @Override // InputConnection + // We need to suppress lint complaining about the lack override here in the meantime: it wants us to build + // against sdk 24, even though we're using 23, and therefore complains about the lack of override. + // Once we update to 24, we can use the actual override annotation and remove the lint suppression. + @SuppressLint("Override") + public Handler getHandler() { + if (isPhysicalKeyboardPresent()) { + return ThreadUtils.getUiHandler(); + } + + return getBackgroundHandler(); + } + + @Override // InputConnectionListener + public Handler getHandler(Handler defHandler) { + if (!canReturnCustomHandler()) { + return defHandler; + } + + return mEditableClient.setInputConnectionHandler(getHandler()); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + // Some keyboards require us to fill out outAttrs even if we return null. + outAttrs.inputType = InputType.TYPE_CLASS_TEXT; + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE; + outAttrs.actionLabel = null; + + if (mIMEState == IME_STATE_DISABLED) { + hideSoftInput(); + return null; + } + + if (mIMEState == IME_STATE_PASSWORD || + "password".equalsIgnoreCase(mIMETypeHint)) + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD; + else if (mIMEState == IME_STATE_PLUGIN) + outAttrs.inputType = InputType.TYPE_NULL; // "send key events" mode + else if (mIMETypeHint.equalsIgnoreCase("url")) + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI; + else if (mIMETypeHint.equalsIgnoreCase("email")) + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + else if (mIMETypeHint.equalsIgnoreCase("tel")) + outAttrs.inputType = InputType.TYPE_CLASS_PHONE; + else if (mIMETypeHint.equalsIgnoreCase("number") || + mIMETypeHint.equalsIgnoreCase("range")) + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER + | InputType.TYPE_NUMBER_FLAG_SIGNED + | InputType.TYPE_NUMBER_FLAG_DECIMAL; + else if (mIMETypeHint.equalsIgnoreCase("week") || + mIMETypeHint.equalsIgnoreCase("month")) + outAttrs.inputType = InputType.TYPE_CLASS_DATETIME + | InputType.TYPE_DATETIME_VARIATION_DATE; + else if (mIMEModeHint.equalsIgnoreCase("numeric")) + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | + InputType.TYPE_NUMBER_FLAG_SIGNED | + InputType.TYPE_NUMBER_FLAG_DECIMAL; + else if (mIMEModeHint.equalsIgnoreCase("digit")) + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER; + else { + // TYPE_TEXT_FLAG_IME_MULTI_LINE flag makes the fullscreen IME line wrap + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_AUTO_CORRECT | + InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE; + if (mIMETypeHint.equalsIgnoreCase("textarea") || + mIMETypeHint.length() == 0) { + // empty mIMETypeHint indicates contentEditable/designMode documents + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_MULTI_LINE; + } + if (mIMEModeHint.equalsIgnoreCase("uppercase")) + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS; + else if (mIMEModeHint.equalsIgnoreCase("titlecase")) + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS; + else if (mIMETypeHint.equalsIgnoreCase("text") && + !mIMEModeHint.equalsIgnoreCase("autocapitalized")) + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_NORMAL; + else if (!mIMEModeHint.equalsIgnoreCase("lowercase")) + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + // auto-capitalized mode is the default for types other than text + } + + if (mIMEActionHint.equalsIgnoreCase("go")) + outAttrs.imeOptions = EditorInfo.IME_ACTION_GO; + else if (mIMEActionHint.equalsIgnoreCase("done")) + outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE; + else if (mIMEActionHint.equalsIgnoreCase("next")) + outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT; + else if (mIMEActionHint.equalsIgnoreCase("search") || + mIMETypeHint.equalsIgnoreCase("search")) + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH; + else if (mIMEActionHint.equalsIgnoreCase("send")) + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND; + else if (mIMEActionHint.length() > 0) { + if (DEBUG) + Log.w(LOGTAG, "Unexpected mIMEActionHint=\"" + mIMEActionHint + "\""); + outAttrs.actionLabel = mIMEActionHint; + } + + 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); + } + + if (mIMEState == IME_STATE_PLUGIN) { + // Since we are using a temporary string as the editable, the selection is at 0 + outAttrs.initialSelStart = 0; + outAttrs.initialSelEnd = 0; + return mKeyInputConnection; + } + Editable editable = getEditable(); + outAttrs.initialSelStart = Selection.getSelectionStart(editable); + outAttrs.initialSelEnd = Selection.getSelectionEnd(editable); + + showSoftInput(); + 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); + } + + /* package */ void sendKeyEvent(final int action, KeyEvent event) { + final Editable editable = getEditable(); + if (editable == null) { + return; + } + + final KeyListener keyListener = TextKeyListener.getInstance(); + event = translateKey(event.getKeyCode(), event); + + // We only let TextKeyListener do UI things on the UI thread. + final View v = ThreadUtils.isOnUiThread() ? getView() : null; + final int keyCode = event.getKeyCode(); + final boolean handled; + + if (shouldSkipKeyListener(keyCode, event)) { + handled = false; + } else if (action == KeyEvent.ACTION_DOWN) { + mEditableClient.setSuppressKeyUp(true); + handled = keyListener.onKeyDown(v, editable, keyCode, event); + } else if (action == KeyEvent.ACTION_UP) { + handled = keyListener.onKeyUp(v, editable, keyCode, event); + } else { + handled = keyListener.onKeyOther(v, editable, event); + } + + if (!handled) { + mEditableClient.sendKeyEvent(event, action, TextKeyListener.getMetaState(editable)); + } + + if (action == KeyEvent.ACTION_DOWN) { + if (!handled) { + // Usually, the down key listener call above adjusts meta states for us. + // However, if the call didn't handle the event, we have to manually + // adjust meta states so the meta states remain consistent. + TextKeyListener.adjustMetaAfterKeypress(editable); + } + mEditableClient.setSuppressKeyUp(false); + } + } + + @Override + public boolean sendKeyEvent(KeyEvent event) { + sendKeyEvent(event.getAction(), event); + return false; // seems to always return false + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + return false; + } + + private boolean shouldProcessKey(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_MENU: + case KeyEvent.KEYCODE_BACK: + case KeyEvent.KEYCODE_VOLUME_UP: + case KeyEvent.KEYCODE_VOLUME_DOWN: + case KeyEvent.KEYCODE_SEARCH: + // ignore HEADSETHOOK to allow hold-for-voice-search to work + case KeyEvent.KEYCODE_HEADSETHOOK: + return false; + } + return true; + } + + private boolean shouldSkipKeyListener(int keyCode, KeyEvent event) { + if (mIMEState == IME_STATE_DISABLED || + mIMEState == IME_STATE_PLUGIN) { + return true; + } + // Preserve enter and tab keys for the browser + if (keyCode == KeyEvent.KEYCODE_ENTER || + keyCode == KeyEvent.KEYCODE_TAB) { + return true; + } + // BaseKeyListener returns false even if it handled these keys for us, + // so we skip the key listener entirely and handle these ourselves + if (keyCode == KeyEvent.KEYCODE_DEL || + keyCode == KeyEvent.KEYCODE_FORWARD_DEL) { + return true; + } + return false; + } + + private KeyEvent translateKey(int keyCode, 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; + } + + if (GamepadUtils.isSonyXperiaGamepadKeyEvent(event)) { + return GamepadUtils.translateSonyXperiaGamepadKeys(keyCode, event); + } + + 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 (AppConstants.Versions.feature19Plus) { + // dispatchMediaKeyEvent is only available on Android 4.4+ + Context viewContext = getView().getContext(); + AudioManager am = (AudioManager)viewContext.getSystemService(Context.AUDIO_SERVICE); + am.dispatchMediaKeyEvent(event); + } + break; + } + } + + private boolean processKey(final int action, final int keyCode, final KeyEvent event) { + + if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) { + return false; + } + + mEditableClient.postToInputConnection(new Runnable() { + @Override + public void run() { + sendKeyEvent(action, event); + } + }); + return true; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + return processKey(KeyEvent.ACTION_DOWN, keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + return processKey(KeyEvent.ACTION_UP, keyCode, event); + } + + /** + * Get a key that represents a given character. + */ + private KeyEvent getCharKeyEvent(final char c) { + final long time = SystemClock.uptimeMillis(); + return new KeyEvent(time, time, KeyEvent.ACTION_MULTIPLE, + KeyEvent.KEYCODE_UNKNOWN, /* repeat */ 0) { + @Override + public int getUnicodeChar() { + return c; + } + + @Override + public int getUnicodeChar(int metaState) { + return c; + } + }; + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, final KeyEvent event) { + if (keyCode == KeyEvent.KEYCODE_UNKNOWN) { + // KEYCODE_UNKNOWN means the characters are in KeyEvent.getCharacters() + final String str = event.getCharacters(); + for (int i = 0; i < str.length(); i++) { + final KeyEvent charEvent = getCharKeyEvent(str.charAt(i)); + if (!processKey(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent) || + !processKey(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) { + return false; + } + } + return true; + } + + while ((repeatCount--) != 0) { + if (!processKey(KeyEvent.ACTION_DOWN, keyCode, event) || + !processKey(KeyEvent.ACTION_UP, keyCode, event)) { + return false; + } + } + return true; + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + View v = getView(); + switch (keyCode) { + case KeyEvent.KEYCODE_MENU: + InputMethodManager imm = getInputMethodManager(); + imm.toggleSoftInputFromWindow(v.getWindowToken(), + InputMethodManager.SHOW_FORCED, 0); + return true; + default: + break; + } + return false; + } + + @Override + public boolean isIMEEnabled() { + // make sure this picks up PASSWORD and PLUGIN states as well + return mIMEState != IME_STATE_DISABLED; + } + + @Override + public void notifyIME(int type) { + switch (type) { + + case NOTIFY_IME_OF_FOCUS: + // Showing/hiding vkb is done in notifyIMEContext + mFocused = true; + resetInputConnection(); + break; + + case NOTIFY_IME_OF_BLUR: + // Showing/hiding vkb is done in notifyIMEContext + mFocused = false; + break; + + case NOTIFY_IME_OPEN_VKB: + showSoftInput(); + break; + + default: + if (DEBUG) { + throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type); + } + break; + } + } + + @Override + public void notifyIMEContext(int state, String typeHint, String modeHint, String actionHint) { + // For some input type we will use a widget to display the ui, for those we must not + // display the ime. We can display a widget for date and time types and, if the sdk version + // is 11 or greater, for datetime/month/week as well. + if (typeHint != null && + (typeHint.equalsIgnoreCase("date") || + typeHint.equalsIgnoreCase("time") || + typeHint.equalsIgnoreCase("datetime") || + typeHint.equalsIgnoreCase("month") || + typeHint.equalsIgnoreCase("week") || + typeHint.equalsIgnoreCase("datetime-local"))) { + state = IME_STATE_DISABLED; + } + + // 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; + mIMETypeHint = (typeHint == null) ? "" : typeHint; + mIMEModeHint = (modeHint == null) ? "" : modeHint; + mIMEActionHint = (actionHint == null) ? "" : actionHint; + + // These fields are reset here and will be updated when restartInput is called below + mUpdateRequest = null; + mCurrentInputMethod = ""; + + View v = getView(); + if (v == null || !v.hasFocus()) { + // When using Find In Page, we can still receive notifyIMEContext calls due to the + // selection changing when highlighting. However in this case we don't want to reset/ + // show/hide the keyboard because the find box has the focus and is taking input from + // the keyboard. + return; + } + + // On focus, the notifyIMEContext call comes *before* the + // notifyIME(NOTIFY_IME_OF_FOCUS) call, but we need to call restartInput during + // notifyIME, so we skip restartInput here. On blur, the notifyIMEContext call + // comes *after* the notifyIME(NOTIFY_IME_OF_BLUR) call, and we need to call + // restartInput here. + if (mIMEState == IME_STATE_DISABLED || mFocused) { + restartInput(); + } + } +} + +final class DebugGeckoInputConnection + extends GeckoInputConnection + implements InvocationHandler { + + private InputConnection mProxy; + private final StringBuilder mCallLevel; + + private DebugGeckoInputConnection(View targetView, + GeckoEditableClient editable) { + super(targetView, editable); + mCallLevel = new StringBuilder(); + } + + public static GeckoEditableListener create(View targetView, + GeckoEditableClient editable) { + final Class<?>[] PROXY_INTERFACES = { InputConnection.class, + InputConnectionListener.class, + GeckoEditableListener.class }; + DebugGeckoInputConnection dgic = + new DebugGeckoInputConnection(targetView, editable); + dgic.mProxy = (InputConnection)Proxy.newProxyInstance( + GeckoInputConnection.class.getClassLoader(), + PROXY_INTERFACES, dgic); + return (GeckoEditableListener)dgic.mProxy; + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) + throws Throwable { + + StringBuilder log = new StringBuilder(mCallLevel); + log.append("> ").append(method.getName()).append("("); + if (args != null) { + for (Object arg : args) { + // translate argument values to constant names + if ("notifyIME".equals(method.getName()) && arg == args[0]) { + log.append(GeckoEditable.getConstantName( + GeckoEditableListener.class, "NOTIFY_IME_", arg)); + } else if ("notifyIMEContext".equals(method.getName()) && arg == args[0]) { + log.append(GeckoEditable.getConstantName( + GeckoEditableListener.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(this, args); + if (ret == this) { + ret = mProxy; + } + 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; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java new file mode 100644 index 000000000..0cb56a7d2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java @@ -0,0 +1,491 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.NetworkUtils; +import org.mozilla.gecko.util.NetworkUtils.ConnectionSubType; +import org.mozilla.gecko.util.NetworkUtils.ConnectionType; +import org.mozilla.gecko.util.NetworkUtils.NetworkStatus; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.net.DhcpInfo; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.telephony.TelephonyManager; +import android.text.format.Formatter; +import android.util.Log; + +/** + * Provides connection type, subtype and general network status (up/down). + * + * According to spec of Network Information API version 3, connection types include: + * bluetooth, cellular, ethernet, none, wifi and other. The objective of providing such general + * connection is due to some security concerns. In short, we don't want to expose exact network type, + * especially the cellular network type. + * + * Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets. + * + * Logic is implemented as a state machine, so see the transition matrix to figure out what happens when. + * This class depends on access to the context, so only use after GeckoAppShell has been initialized. + */ +public class GeckoNetworkManager extends BroadcastReceiver implements NativeEventListener { + private static final String LOGTAG = "GeckoNetworkManager"; + + private static final String LINK_DATA_CHANGED = "changed"; + + private static GeckoNetworkManager instance; + + // We hackishly (yet harmlessly, in this case) keep a Context reference passed in via the start method. + // See context handling notes in handleManagerEvent, and Bug 1277333. + private Context context; + + public static void destroy() { + if (instance != null) { + instance.onDestroy(); + instance = null; + } + } + + public enum ManagerState { + OffNoListeners, + OffWithListeners, + OnNoListeners, + OnWithListeners + } + + public enum ManagerEvent { + start, + stop, + enableNotifications, + disableNotifications, + receivedUpdate + } + + private ManagerState currentState = ManagerState.OffNoListeners; + private ConnectionType currentConnectionType = ConnectionType.NONE; + private ConnectionType previousConnectionType = ConnectionType.NONE; + private ConnectionSubType currentConnectionSubtype = ConnectionSubType.UNKNOWN; + private ConnectionSubType previousConnectionSubtype = ConnectionSubType.UNKNOWN; + private NetworkStatus currentNetworkStatus = NetworkStatus.UNKNOWN; + private NetworkStatus previousNetworkStatus = NetworkStatus.UNKNOWN; + + private enum InfoType { + MCC, + MNC + } + + private GeckoNetworkManager() { + EventDispatcher.getInstance().registerGeckoThreadListener(this, + "Wifi:Enable", + "Wifi:GetIPAddress"); + } + + private void onDestroy() { + handleManagerEvent(ManagerEvent.stop); + EventDispatcher.getInstance().unregisterGeckoThreadListener(this, + "Wifi:Enable", + "Wifi:GetIPAddress"); + } + + public static GeckoNetworkManager getInstance() { + if (instance == null) { + instance = new GeckoNetworkManager(); + } + + return instance; + } + + public double[] getCurrentInformation() { + final Context applicationContext = GeckoAppShell.getApplicationContext(); + final ConnectionType connectionType = currentConnectionType; + return new double[] { + connectionType.value, + connectionType == ConnectionType.WIFI ? 1.0 : 0.0, + connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0 + }; + } + + @Override + public void onReceive(Context aContext, Intent aIntent) { + handleManagerEvent(ManagerEvent.receivedUpdate); + } + + public void start(final Context context) { + this.context = context; + handleManagerEvent(ManagerEvent.start); + } + + public void stop() { + handleManagerEvent(ManagerEvent.stop); + } + + public void enableNotifications() { + handleManagerEvent(ManagerEvent.enableNotifications); + } + + public void disableNotifications() { + handleManagerEvent(ManagerEvent.disableNotifications); + } + + /** + * For a given event, figure out the next state, run any transition by-product actions, and switch + * current state to the next state. If event is invalid for the current state, this is a no-op. + * + * @param event Incoming event + * @return Boolean indicating if transition was performed. + */ + private synchronized boolean handleManagerEvent(ManagerEvent event) { + final ManagerState nextState = getNextState(currentState, event); + + Log.d(LOGTAG, "Incoming event " + event + " for state " + currentState + " -> " + nextState); + if (nextState == null) { + Log.w(LOGTAG, "Invalid event " + event + " for state " + currentState); + return false; + } + + // We're being deliberately careful about handling context here; it's possible that in some + // rare cases and possibly related to timing of when this is called (seems to be early in the startup phase), + // GeckoAppShell.getApplicationContext() will be null, and .start() wasn't called yet, + // so we don't have a local Context reference either. If both of these are true, we have to drop the event. + // NB: this is hacky (and these checks attempt to isolate the hackiness), and root cause + // seems to be how this class fits into the larger ecosystem and general flow of events. + // See Bug 1277333. + final Context contextForAction; + if (context != null) { + contextForAction = context; + } else { + contextForAction = GeckoAppShell.getApplicationContext(); + } + + if (contextForAction == null) { + Log.w(LOGTAG, "Context is not available while processing event " + event + " for state " + currentState); + return false; + } + + performActionsForStateEvent(contextForAction, currentState, event); + currentState = nextState; + + return true; + } + + /** + * Defines a transition matrix for our state machine. For a given state/event pair, returns nextState. + * + * @param currentState Current state against which we have an incoming event + * @param event Incoming event for which we'd like to figure out the next state + * @return State into which we should transition as result of given event + */ + @Nullable + public static ManagerState getNextState(@NonNull ManagerState currentState, @NonNull ManagerEvent event) { + switch (currentState) { + case OffNoListeners: + switch (event) { + case start: + return ManagerState.OnNoListeners; + case enableNotifications: + return ManagerState.OffWithListeners; + default: + return null; + } + case OnNoListeners: + switch (event) { + case stop: + return ManagerState.OffNoListeners; + case enableNotifications: + return ManagerState.OnWithListeners; + case receivedUpdate: + return ManagerState.OnNoListeners; + default: + return null; + } + case OnWithListeners: + switch (event) { + case stop: + return ManagerState.OffWithListeners; + case disableNotifications: + return ManagerState.OnNoListeners; + case receivedUpdate: + return ManagerState.OnWithListeners; + default: + return null; + } + case OffWithListeners: + switch (event) { + case start: + return ManagerState.OnWithListeners; + case disableNotifications: + return ManagerState.OffNoListeners; + default: + return null; + } + default: + throw new IllegalStateException("Unknown current state: " + currentState.name()); + } + } + + /** + * For a given state/event combination, run any actions which are by-products of leaving the state + * because of a given event. Since this is a deterministic state machine, we can easily do that + * without any additional information. + * + * @param currentState State which we are leaving + * @param event Event which is causing us to leave the state + */ + private void performActionsForStateEvent(final Context context, final ManagerState currentState, final ManagerEvent event) { + // NB: network state might be queried via getCurrentInformation at any time; pre-rewrite behaviour was + // that network state was updated whenever enableNotifications was called. To avoid deviating + // from previous behaviour and causing weird side-effects, we call updateNetworkStateAndConnectionType + // whenever notifications are enabled. + switch (currentState) { + case OffNoListeners: + if (event == ManagerEvent.start) { + updateNetworkStateAndConnectionType(context); + registerBroadcastReceiver(context, this); + } + if (event == ManagerEvent.enableNotifications) { + updateNetworkStateAndConnectionType(context); + } + break; + case OnNoListeners: + if (event == ManagerEvent.receivedUpdate) { + updateNetworkStateAndConnectionType(context); + sendNetworkStateToListeners(context); + } + if (event == ManagerEvent.enableNotifications) { + updateNetworkStateAndConnectionType(context); + registerBroadcastReceiver(context, this); + } + if (event == ManagerEvent.stop) { + unregisterBroadcastReceiver(context, this); + } + break; + case OnWithListeners: + if (event == ManagerEvent.receivedUpdate) { + updateNetworkStateAndConnectionType(context); + sendNetworkStateToListeners(context); + } + if (event == ManagerEvent.stop) { + unregisterBroadcastReceiver(context, this); + } + /* no-op event: ManagerEvent.disableNotifications */ + break; + case OffWithListeners: + if (event == ManagerEvent.start) { + registerBroadcastReceiver(context, this); + } + /* no-op event: ManagerEvent.disableNotifications */ + break; + default: + throw new IllegalStateException("Unknown current state: " + currentState.name()); + } + } + + /** + * Update current network state and connection types. + */ + private void updateNetworkStateAndConnectionType(final Context context) { + final ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService( + Context.CONNECTIVITY_SERVICE); + // Type/status getters below all have a defined behaviour for when connectivityManager == null + if (connectivityManager == null) { + Log.e(LOGTAG, "ConnectivityManager does not exist."); + } + currentConnectionType = NetworkUtils.getConnectionType(connectivityManager); + currentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager); + currentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager); + Log.d(LOGTAG, "New network state: " + currentNetworkStatus + ", " + currentConnectionType + ", " + currentConnectionSubtype); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void onConnectionChanged(int type, String subType, + boolean isWifi, int DHCPGateway); + + @WrapForJNI(dispatchTo = "gecko") + private static native void onStatusChanged(String status); + + /** + * Send current network state and connection type to whomever is listening. + */ + private void sendNetworkStateToListeners(final Context context) { + if (currentConnectionType != previousConnectionType || + currentConnectionSubtype != previousConnectionSubtype) { + previousConnectionType = currentConnectionType; + previousConnectionSubtype = currentConnectionSubtype; + + final boolean isWifi = currentConnectionType == ConnectionType.WIFI; + final int gateway = !isWifi ? 0 : + wifiDhcpGatewayAddress(context); + + if (GeckoThread.isRunning()) { + onConnectionChanged(currentConnectionType.value, + currentConnectionSubtype.value, isWifi, gateway); + } else { + GeckoThread.queueNativeCall(GeckoNetworkManager.class, "onConnectionChanged", + currentConnectionType.value, + String.class, currentConnectionSubtype.value, + isWifi, gateway); + } + } + + final String status; + + if (currentNetworkStatus != previousNetworkStatus) { + previousNetworkStatus = currentNetworkStatus; + status = currentNetworkStatus.value; + } else { + status = LINK_DATA_CHANGED; + } + + if (GeckoThread.isRunning()) { + onStatusChanged(status); + } else { + GeckoThread.queueNativeCall(GeckoNetworkManager.class, "onStatusChanged", + String.class, status); + } + } + + /** + * Stop listening for network state updates. + */ + private static void unregisterBroadcastReceiver(final Context context, final BroadcastReceiver receiver) { + context.unregisterReceiver(receiver); + } + + /** + * Start listening for network state updates. + */ + private static void registerBroadcastReceiver(final Context context, final BroadcastReceiver receiver) { + final IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + context.registerReceiver(receiver, filter); + } + + private static int wifiDhcpGatewayAddress(final Context context) { + if (context == null) { + return 0; + } + + try { + WifiManager mgr = (WifiManager) context.getSystemService(Context.WIFI_SERVICE); + DhcpInfo d = mgr.getDhcpInfo(); + if (d == null) { + return 0; + } + + return d.gateway; + + } catch (Exception ex) { + // getDhcpInfo() is not documented to require any permissions, but on some devices + // requires android.permission.ACCESS_WIFI_STATE. Just catch the generic exception + // here and returning 0. Not logging because this could be noisy. + return 0; + } + } + + @Override + /** + * Handles native messages, not part of the state machine flow. + */ + public void handleMessage(final String event, final NativeJSObject message, + final EventCallback callback) { + final Context applicationContext = GeckoAppShell.getApplicationContext(); + switch (event) { + case "Wifi:Enable": + final WifiManager mgr = (WifiManager) applicationContext.getSystemService(Context.WIFI_SERVICE); + + if (!mgr.isWifiEnabled()) { + mgr.setWifiEnabled(true); + } else { + // If Wifi is enabled, maybe you need to select a network + Intent intent = new Intent(android.provider.Settings.ACTION_WIFI_SETTINGS); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + applicationContext.startActivity(intent); + } + break; + case "Wifi:GetIPAddress": + getWifiIPAddress(callback); + break; + } + } + + // This function only works for IPv4; not part of the state machine flow. + private void getWifiIPAddress(final EventCallback callback) { + final WifiManager mgr = (WifiManager) GeckoAppShell.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + + if (mgr == null) { + callback.sendError("Cannot get WifiManager"); + return; + } + + final WifiInfo info = mgr.getConnectionInfo(); + if (info == null) { + callback.sendError("Cannot get connection info"); + return; + } + + int ip = info.getIpAddress(); + if (ip == 0) { + callback.sendError("Cannot get IPv4 address"); + return; + } + callback.sendSuccess(Formatter.formatIpAddress(ip)); + } + + private static int getNetworkOperator(InfoType type, Context context) { + if (null == context) { + return -1; + } + + TelephonyManager tel = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE); + if (tel == null) { + Log.e(LOGTAG, "Telephony service does not exist"); + return -1; + } + + String networkOperator = tel.getNetworkOperator(); + if (networkOperator == null || networkOperator.length() <= 3) { + return -1; + } + + if (type == InfoType.MNC) { + return Integer.parseInt(networkOperator.substring(3)); + } + + if (type == InfoType.MCC) { + return Integer.parseInt(networkOperator.substring(0, 3)); + } + + return -1; + } + + /** + * These are called from JavaScript ctypes. Avoid letting ProGuard delete them. + * + * Note that these methods must only be called after GeckoAppShell has been + * initialized: they depend on access to the context. + * + * Not part of the state machine flow. + */ + @JNITarget + public static int getMCC() { + return getNetworkOperator(InfoType.MCC, GeckoAppShell.getApplicationContext()); + } + + @JNITarget + public static int getMNC() { + return getNetworkOperator(InfoType.MNC, GeckoAppShell.getApplicationContext()); + } +} 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); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java new file mode 100644 index 000000000..2afb54bc4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoProfileDirectories.java @@ -0,0 +1,230 @@ +/* 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 java.io.File; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.util.INIParser; +import org.mozilla.gecko.util.INISection; + +import android.content.Context; + +/** + * <code>GeckoProfileDirectories</code> manages access to mappings from profile + * names to salted profile directory paths, as well as the default profile name. + * + * This class will eventually come to encapsulate the remaining logic embedded + * in profiles.ini; for now it's a read-only wrapper. + */ +public class GeckoProfileDirectories { + @SuppressWarnings("serial") + public static class NoMozillaDirectoryException extends Exception { + public NoMozillaDirectoryException(Throwable cause) { + super(cause); + } + + public NoMozillaDirectoryException(String reason) { + super(reason); + } + + public NoMozillaDirectoryException(String reason, Throwable cause) { + super(reason, cause); + } + } + + @SuppressWarnings("serial") + public static class NoSuchProfileException extends Exception { + public NoSuchProfileException(String detailMessage, Throwable cause) { + super(detailMessage, cause); + } + + public NoSuchProfileException(String detailMessage) { + super(detailMessage); + } + } + + private interface INISectionPredicate { + public boolean matches(INISection section); + } + + private static final String MOZILLA_DIR_NAME = "mozilla"; + + /** + * Returns true if the supplied profile entry represents the default profile. + */ + private static final INISectionPredicate sectionIsDefault = new INISectionPredicate() { + @Override + public boolean matches(INISection section) { + return section.getIntProperty("Default") == 1; + } + }; + + /** + * Returns true if the supplied profile entry has a 'Name' field. + */ + private static final INISectionPredicate sectionHasName = new INISectionPredicate() { + @Override + public boolean matches(INISection section) { + final String name = section.getStringProperty("Name"); + return name != null; + } + }; + + @RobocopTarget + public static INIParser getProfilesINI(File mozillaDir) { + return new INIParser(new File(mozillaDir, "profiles.ini")); + } + + /** + * Utility method to compute a salted profile name: eight random alphanumeric + * characters, followed by a period, followed by the profile name. + */ + public static String saltProfileName(final String name) { + if (name == null) { + throw new IllegalArgumentException("Cannot salt null profile name."); + } + + final String allowedChars = "abcdefghijklmnopqrstuvwxyz0123456789"; + final int scale = allowedChars.length(); + final int saltSize = 8; + + final StringBuilder saltBuilder = new StringBuilder(saltSize + 1 + name.length()); + for (int i = 0; i < saltSize; i++) { + saltBuilder.append(allowedChars.charAt((int)(Math.random() * scale))); + } + saltBuilder.append('.'); + saltBuilder.append(name); + return saltBuilder.toString(); + } + + /** + * Return the Mozilla directory within the files directory of the provided + * context. This should always be the same within a running application. + * + * This method is package-scoped so that new {@link GeckoProfile} instances can + * contextualize themselves. + * + * @return a new File object for the Mozilla directory. + * @throws NoMozillaDirectoryException + * if the directory did not exist and could not be created. + */ + @RobocopTarget + public static File getMozillaDirectory(Context context) throws NoMozillaDirectoryException { + final File mozillaDir = new File(context.getFilesDir(), MOZILLA_DIR_NAME); + if (mozillaDir.mkdirs() || mozillaDir.isDirectory()) { + return mozillaDir; + } + + // Although this leaks a path to the system log, the path is + // predictable (unlike a profile directory), so this is fine. + throw new NoMozillaDirectoryException("Unable to create mozilla directory at " + mozillaDir.getAbsolutePath()); + } + + /** + * Discover the default profile name by examining profiles.ini. + * + * Package-scoped because {@link GeckoProfile} needs access to it. + * + * @return null if there is no "Default" entry in profiles.ini, or the profile + * name if there is. + * @throws NoMozillaDirectoryException + * if the Mozilla directory did not exist and could not be created. + */ + static String findDefaultProfileName(final Context context) throws NoMozillaDirectoryException { + final INIParser parser = GeckoProfileDirectories.getProfilesINI(getMozillaDirectory(context)); + if (parser.getSections() != null) { + for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) { + final INISection section = e.nextElement(); + if (section.getIntProperty("Default") == 1) { + return section.getStringProperty("Name"); + } + } + } + return null; + } + + static Map<String, String> getDefaultProfile(final File mozillaDir) { + return getMatchingProfiles(mozillaDir, sectionIsDefault, true); + } + + static Map<String, String> getProfilesNamed(final File mozillaDir, final String name) { + final INISectionPredicate predicate = new INISectionPredicate() { + @Override + public boolean matches(final INISection section) { + return name.equals(section.getStringProperty("Name")); + } + }; + return getMatchingProfiles(mozillaDir, predicate, true); + } + + /** + * Calls {@link GeckoProfileDirectories#getMatchingProfiles(File, INISectionPredicate, boolean)} + * with a filter to ensure that all profiles are named. + */ + static Map<String, String> getAllProfiles(final File mozillaDir) { + return getMatchingProfiles(mozillaDir, sectionHasName, false); + } + + /** + * Return a mapping from the names of all matching profiles (that is, + * profiles appearing in profiles.ini that match the supplied predicate) to + * their absolute paths on disk. + * + * @param mozillaDir + * a directory containing profiles.ini. + * @param predicate + * a predicate to use when evaluating whether to include a + * particular INI section. + * @param stopOnSuccess + * if true, this method will return with the first result that + * matches the predicate; if false, all matching results are + * included. + * @return a {@link Map} from name to path. + */ + public static Map<String, String> getMatchingProfiles(final File mozillaDir, INISectionPredicate predicate, boolean stopOnSuccess) { + final HashMap<String, String> result = new HashMap<String, String>(); + final INIParser parser = GeckoProfileDirectories.getProfilesINI(mozillaDir); + + if (parser.getSections() != null) { + for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) { + final INISection section = e.nextElement(); + if (predicate == null || predicate.matches(section)) { + final String name = section.getStringProperty("Name"); + final String pathString = section.getStringProperty("Path"); + final boolean isRelative = section.getIntProperty("IsRelative") == 1; + final File path = isRelative ? new File(mozillaDir, pathString) : new File(pathString); + result.put(name, path.getAbsolutePath()); + + if (stopOnSuccess) { + return result; + } + } + } + } + return result; + } + + public static File findProfileDir(final File mozillaDir, final String profileName) throws NoSuchProfileException { + // Open profiles.ini to find the correct path. + final INIParser parser = GeckoProfileDirectories.getProfilesINI(mozillaDir); + if (parser.getSections() != null) { + for (Enumeration<INISection> e = parser.getSections().elements(); e.hasMoreElements(); ) { + final INISection section = e.nextElement(); + final String name = section.getStringProperty("Name"); + if (name != null && name.equals(profileName)) { + if (section.getIntProperty("IsRelative") == 1) { + return new File(mozillaDir, section.getStringProperty("Path")); + } + return new File(section.getStringProperty("Path")); + } + } + } + throw new NoSuchProfileException("No profile " + profileName); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java new file mode 100644 index 000000000..23f84f52a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java @@ -0,0 +1,423 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.WrapForJNI; + +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.util.Log; +import android.view.Surface; +import android.app.Activity; + +import java.util.Arrays; +import java.util.List; + +/* + * Updates, locks and unlocks the screen orientation. + * + * Note: Replaces the OnOrientationChangeListener to avoid redundant rotation + * event handling. + */ +public class GeckoScreenOrientation { + private static final String LOGTAG = "GeckoScreenOrientation"; + + // Make sure that any change in dom/base/ScreenOrientation.h happens here too. + public enum ScreenOrientation { + NONE(0), + PORTRAIT_PRIMARY(1 << 0), + PORTRAIT_SECONDARY(1 << 1), + PORTRAIT(PORTRAIT_PRIMARY.value | PORTRAIT_SECONDARY.value), + LANDSCAPE_PRIMARY(1 << 2), + LANDSCAPE_SECONDARY(1 << 3), + LANDSCAPE(LANDSCAPE_PRIMARY.value | LANDSCAPE_SECONDARY.value), + DEFAULT(1 << 4); + + public final short value; + + private ScreenOrientation(int value) { + this.value = (short)value; + } + + private final static ScreenOrientation[] sValues = ScreenOrientation.values(); + + public static ScreenOrientation get(int value) { + for (ScreenOrientation orient: sValues) { + if (orient.value == value) { + return orient; + } + } + return NONE; + } + } + + // Singleton instance. + private static GeckoScreenOrientation sInstance; + // Default screen orientation, used for initialization and unlocking. + private static final ScreenOrientation DEFAULT_SCREEN_ORIENTATION = ScreenOrientation.DEFAULT; + // Default rotation, used when device rotation is unknown. + private static final int DEFAULT_ROTATION = Surface.ROTATION_0; + // Default orientation, used if screen orientation is unspecified. + private ScreenOrientation mDefaultScreenOrientation; + // Last updated screen orientation. + private ScreenOrientation mScreenOrientation; + // Whether the update should notify Gecko about screen orientation changes. + private boolean mShouldNotify = true; + // Configuration screen orientation preference path. + private static final String DEFAULT_SCREEN_ORIENTATION_PREF = "app.orientation.default"; + + public GeckoScreenOrientation() { + PrefsHelper.getPref(DEFAULT_SCREEN_ORIENTATION_PREF, new PrefsHelper.PrefHandlerBase() { + @Override public void prefValue(String pref, String value) { + // Read and update the configuration default preference. + mDefaultScreenOrientation = screenOrientationFromArrayString(value); + setRequestedOrientation(mDefaultScreenOrientation); + } + }); + + mDefaultScreenOrientation = DEFAULT_SCREEN_ORIENTATION; + update(); + } + + public static GeckoScreenOrientation getInstance() { + if (sInstance == null) { + sInstance = new GeckoScreenOrientation(); + } + return sInstance; + } + + /* + * Enable Gecko screen orientation events on update. + */ + public void enableNotifications() { + update(); + mShouldNotify = true; + } + + /* + * Disable Gecko screen orientation events on update. + */ + public void disableNotifications() { + mShouldNotify = false; + } + + /* + * Update screen orientation. + * Retrieve orientation and rotation via GeckoAppShell. + * + * @return Whether the screen orientation has changed. + */ + public boolean update() { + Activity activity = getActivity(); + if (activity == null) { + return false; + } + Configuration config = activity.getResources().getConfiguration(); + return update(config.orientation); + } + + /* + * Update screen orientation given the android orientation. + * Retrieve rotation via GeckoAppShell. + * + * @param aAndroidOrientation + * Android screen orientation from Configuration.orientation. + * + * @return Whether the screen orientation has changed. + */ + public boolean update(int aAndroidOrientation) { + return update(getScreenOrientation(aAndroidOrientation, getRotation())); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void onOrientationChange(short screenOrientation, short angle); + + /* + * Update screen orientation given the screen orientation. + * + * @param aScreenOrientation + * Gecko screen orientation based on android orientation and rotation. + * + * @return Whether the screen orientation has changed. + */ + public boolean update(ScreenOrientation aScreenOrientation) { + if (mScreenOrientation == aScreenOrientation) { + return false; + } + mScreenOrientation = aScreenOrientation; + Log.d(LOGTAG, "updating to new orientation " + mScreenOrientation); + if (mShouldNotify) { + // Gecko expects a definite screen orientation, so we default to the + // primary orientations. + if (aScreenOrientation == ScreenOrientation.PORTRAIT) { + aScreenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + } else if (aScreenOrientation == ScreenOrientation.LANDSCAPE) { + aScreenOrientation = ScreenOrientation.LANDSCAPE_PRIMARY; + } + + if (GeckoThread.isRunning()) { + onOrientationChange(aScreenOrientation.value, getAngle()); + } else { + GeckoThread.queueNativeCall(GeckoScreenOrientation.class, "onOrientationChange", + aScreenOrientation.value, getAngle()); + } + } + GeckoAppShell.resetScreenSize(); + return true; + } + + /* + * @return The Android orientation (Configuration.orientation). + */ + public int getAndroidOrientation() { + return screenOrientationToAndroidOrientation(getScreenOrientation()); + } + + /* + * @return The Gecko screen orientation derived from Android orientation and + * rotation. + */ + public ScreenOrientation getScreenOrientation() { + return mScreenOrientation; + } + + /* + * Lock screen orientation given the Gecko screen orientation. + * + * @param aGeckoOrientation + * The Gecko orientation provided. + */ + public void lock(int aGeckoOrientation) { + lock(ScreenOrientation.get(aGeckoOrientation)); + } + + /* + * Lock screen orientation given the Gecko screen orientation. + * Retrieve rotation via GeckoAppShell. + * + * @param aScreenOrientation + * Gecko screen orientation derived from Android orientation and + * rotation. + * + * @return Whether the locking was successful. + */ + public boolean lock(ScreenOrientation aScreenOrientation) { + Log.d(LOGTAG, "locking to " + aScreenOrientation); + update(aScreenOrientation); + return setRequestedOrientation(aScreenOrientation); + } + + /* + * Unlock and update screen orientation. + * + * @return Whether the unlocking was successful. + */ + public boolean unlock() { + Log.d(LOGTAG, "unlocking"); + setRequestedOrientation(mDefaultScreenOrientation); + return update(); + } + + private Activity getActivity() { + if (GeckoAppShell.getGeckoInterface() == null) { + return null; + } + return GeckoAppShell.getGeckoInterface().getActivity(); + } + + /* + * Set the given requested orientation for the current activity. + * This is essentially an unlock without an update. + * + * @param aScreenOrientation + * Gecko screen orientation. + * + * @return Whether the requested orientation was set. This can only fail if + * the current activity cannot be retrieved via GeckoAppShell. + * + */ + private boolean setRequestedOrientation(ScreenOrientation aScreenOrientation) { + int activityOrientation = screenOrientationToActivityInfoOrientation(aScreenOrientation); + Activity activity = getActivity(); + if (activity == null) { + Log.w(LOGTAG, "setRequestOrientation: failed to get activity"); + return false; + } + if (activity.getRequestedOrientation() == activityOrientation) { + return false; + } + activity.setRequestedOrientation(activityOrientation); + return true; + } + + /* + * Combine the Android orientation and rotation to the Gecko orientation. + * + * @param aAndroidOrientation + * Android orientation from Configuration.orientation. + * @param aRotation + * Device rotation from Display.getRotation(). + * + * @return Gecko screen orientation. + */ + private ScreenOrientation getScreenOrientation(int aAndroidOrientation, int aRotation) { + boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90; + if (aAndroidOrientation == Configuration.ORIENTATION_PORTRAIT) { + if (isPrimary) { + // Non-rotated portrait device or landscape device rotated + // to primary portrait mode counter-clockwise. + return ScreenOrientation.PORTRAIT_PRIMARY; + } + return ScreenOrientation.PORTRAIT_SECONDARY; + } + if (aAndroidOrientation == Configuration.ORIENTATION_LANDSCAPE) { + if (isPrimary) { + // Non-rotated landscape device or portrait device rotated + // to primary landscape mode counter-clockwise. + return ScreenOrientation.LANDSCAPE_PRIMARY; + } + return ScreenOrientation.LANDSCAPE_SECONDARY; + } + return ScreenOrientation.NONE; + } + + /* + * @return Device rotation converted to an angle. + */ + public short getAngle() { + switch (getRotation()) { + case Surface.ROTATION_0: + return 0; + case Surface.ROTATION_90: + return 90; + case Surface.ROTATION_180: + return 180; + case Surface.ROTATION_270: + return 270; + default: + Log.w(LOGTAG, "getAngle: unexpected rotation value"); + return 0; + } + } + + /* + * @return Device rotation from Display.getRotation(). + */ + private int getRotation() { + Activity activity = getActivity(); + if (activity == null) { + Log.w(LOGTAG, "getRotation: failed to get activity"); + return DEFAULT_ROTATION; + } + return activity.getWindowManager().getDefaultDisplay().getRotation(); + } + + /* + * Retrieve the screen orientation from an array string. + * + * @param aArray + * String containing comma-delimited strings. + * + * @return First parsed Gecko screen orientation. + */ + public static ScreenOrientation screenOrientationFromArrayString(String aArray) { + List<String> orientations = Arrays.asList(aArray.split(",")); + if (orientations.size() == 0) { + // If nothing is listed, return default. + Log.w(LOGTAG, "screenOrientationFromArrayString: no orientation in string"); + return DEFAULT_SCREEN_ORIENTATION; + } + + // We don't support multiple orientations yet. To avoid developer + // confusion, just take the first one listed. + return screenOrientationFromString(orientations.get(0)); + } + + /* + * Retrieve the screen orientation from a string. + * + * @param aStr + * String hopefully containing a screen orientation name. + * @return Gecko screen orientation if matched, DEFAULT_SCREEN_ORIENTATION + * otherwise. + */ + public static ScreenOrientation screenOrientationFromString(String aStr) { + switch (aStr) { + case "portrait": + return ScreenOrientation.PORTRAIT; + case "landscape": + return ScreenOrientation.LANDSCAPE; + case "portrait-primary": + return ScreenOrientation.PORTRAIT_PRIMARY; + case "portrait-secondary": + return ScreenOrientation.PORTRAIT_SECONDARY; + case "landscape-primary": + return ScreenOrientation.LANDSCAPE_PRIMARY; + case "landscape-secondary": + return ScreenOrientation.LANDSCAPE_SECONDARY; + } + + Log.w(LOGTAG, "screenOrientationFromString: unknown orientation string: " + aStr); + return DEFAULT_SCREEN_ORIENTATION; + } + + /* + * Convert Gecko screen orientation to Android orientation. + * + * @param aScreenOrientation + * Gecko screen orientation. + * @return Android orientation. This conversion is lossy, the Android + * orientation does not differentiate between primary and secondary + * orientations. + */ + public static int screenOrientationToAndroidOrientation(ScreenOrientation aScreenOrientation) { + switch (aScreenOrientation) { + case PORTRAIT: + case PORTRAIT_PRIMARY: + case PORTRAIT_SECONDARY: + return Configuration.ORIENTATION_PORTRAIT; + case LANDSCAPE: + case LANDSCAPE_PRIMARY: + case LANDSCAPE_SECONDARY: + return Configuration.ORIENTATION_LANDSCAPE; + case NONE: + case DEFAULT: + default: + return Configuration.ORIENTATION_UNDEFINED; + } + } + + + /* + * Convert Gecko screen orientation to Android ActivityInfo orientation. + * This is yet another orientation used by Android, but it's more detailed + * than the Android orientation. + * It is required for screen orientation locking and unlocking. + * + * @param aScreenOrientation + * Gecko screen orientation. + * @return Android ActivityInfo orientation. + */ + public static int screenOrientationToActivityInfoOrientation(ScreenOrientation aScreenOrientation) { + switch (aScreenOrientation) { + case PORTRAIT: + case PORTRAIT_PRIMARY: + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + case PORTRAIT_SECONDARY: + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + case LANDSCAPE: + case LANDSCAPE_PRIMARY: + return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + case LANDSCAPE_SECONDARY: + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + case DEFAULT: + case NONE: + return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + default: + return ActivityInfo.SCREEN_ORIENTATION_NOSENSOR; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java new file mode 100644 index 000000000..ec928dd86 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSharedPrefs.java @@ -0,0 +1,318 @@ +/* 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 java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.os.StrictMode; +import android.preference.PreferenceManager; +import android.util.Log; + +/** + * {@code GeckoSharedPrefs} provides scoped SharedPreferences instances. + * You should use this API instead of using Context.getSharedPreferences() + * directly. There are four methods to get scoped SharedPreferences instances: + * + * forApp() + * Use it for app-wide, cross-profile pref keys. + * forCrashReporter() + * For the crash reporter, which runs in its own process. + * forProfile() + * Use it to fetch and store keys for the current profile. + * forProfileName() + * Use it to fetch and store keys from/for a specific profile. + * + * {@code GeckoSharedPrefs} has a notion of migrations. Migrations can used to + * migrate keys from one scope to another. You can trigger a new migration by + * incrementing PREFS_VERSION and updating migrateIfNecessary() accordingly. + * + * Migration history: + * 1: Move all PreferenceManager keys to app/profile scopes + * 2: Move the crash reporter's private preferences into their own scope + */ +@RobocopTarget +public final class GeckoSharedPrefs { + private static final String LOGTAG = "GeckoSharedPrefs"; + + // Increment it to trigger a new migration + public static final int PREFS_VERSION = 2; + + // Name for app-scoped prefs + public static final String APP_PREFS_NAME = "GeckoApp"; + + // Name for crash reporter prefs + public static final String CRASH_PREFS_NAME = "CrashReporter"; + + // Used when fetching profile-scoped prefs. + public static final String PROFILE_PREFS_NAME_PREFIX = "GeckoProfile-"; + + // The prefs key that holds the current migration + private static final String PREFS_VERSION_KEY = "gecko_shared_prefs_migration"; + + // For disabling migration when getting a SharedPreferences instance + private static final EnumSet<Flags> disableMigrations = EnumSet.of(Flags.DISABLE_MIGRATIONS); + + // The keys that have to be moved from ProfileManager's default + // shared prefs to the profile from version 0 to 1. + private static final String[] PROFILE_MIGRATIONS_0_TO_1 = { + "home_panels", + "home_locale" + }; + + // The keys that have to be moved from the app prefs + // into the crash reporter's own prefs. + private static final String[] PROFILE_MIGRATIONS_1_TO_2 = { + "sendReport", + "includeUrl", + "allowContact", + "contactEmail" + }; + + // For optimizing the migration check in subsequent get() calls + private static volatile boolean migrationDone; + + public enum Flags { + DISABLE_MIGRATIONS + } + + public static SharedPreferences forApp(Context context) { + return forApp(context, EnumSet.noneOf(Flags.class)); + } + + /** + * Returns an app-scoped SharedPreferences instance. You can disable + * migrations by using the DISABLE_MIGRATIONS flag. + */ + public static SharedPreferences forApp(Context context, EnumSet<Flags> flags) { + if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) { + migrateIfNecessary(context); + } + + return context.getSharedPreferences(APP_PREFS_NAME, 0); + } + + public static SharedPreferences forCrashReporter(Context context) { + return forCrashReporter(context, EnumSet.noneOf(Flags.class)); + } + + /** + * Returns a crash-reporter-scoped SharedPreferences instance. You can disable + * migrations by using the DISABLE_MIGRATIONS flag. + */ + public static SharedPreferences forCrashReporter(Context context, EnumSet<Flags> flags) { + if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) { + migrateIfNecessary(context); + } + + return context.getSharedPreferences(CRASH_PREFS_NAME, 0); + } + + public static SharedPreferences forProfile(Context context) { + return forProfile(context, EnumSet.noneOf(Flags.class)); + } + + /** + * Returns a SharedPreferences instance scoped to the current profile + * in the app. You can disable migrations by using the DISABLE_MIGRATIONS + * flag. + */ + public static SharedPreferences forProfile(Context context, EnumSet<Flags> flags) { + String profileName = GeckoProfile.get(context).getName(); + if (profileName == null) { + throw new IllegalStateException("Could not get current profile name"); + } + + return forProfileName(context, profileName, flags); + } + + public static SharedPreferences forProfileName(Context context, String profileName) { + return forProfileName(context, profileName, EnumSet.noneOf(Flags.class)); + } + + /** + * Returns an SharedPreferences instance scoped to the given profile name. + * You can disable migrations by using the DISABLE_MIGRATION flag. + */ + public static SharedPreferences forProfileName(Context context, String profileName, + EnumSet<Flags> flags) { + if (flags != null && !flags.contains(Flags.DISABLE_MIGRATIONS)) { + migrateIfNecessary(context); + } + + final String prefsName = PROFILE_PREFS_NAME_PREFIX + profileName; + return context.getSharedPreferences(prefsName, 0); + } + + /** + * Returns the current version of the prefs. + */ + public static int getVersion(Context context) { + return forApp(context, disableMigrations).getInt(PREFS_VERSION_KEY, 0); + } + + /** + * Resets migration flag. Should only be used in tests. + */ + public static synchronized void reset() { + migrationDone = false; + } + + /** + * Performs all prefs migrations in the background thread to avoid StrictMode + * exceptions from reading/writing in the UI thread. This method will block + * the current thread until the migration is finished. + */ + private static synchronized void migrateIfNecessary(final Context context) { + if (migrationDone) { + return; + } + + // We deliberately perform the migration in the current thread (which + // is likely the UI thread) as this is actually cheaper than enforcing a + // context switch to another thread (see bug 940575). + // Avoid strict mode warnings when doing so. + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + StrictMode.allowThreadDiskWrites(); + try { + performMigration(context); + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + + migrationDone = true; + } + + private static void performMigration(Context context) { + final SharedPreferences appPrefs = forApp(context, disableMigrations); + + final int currentVersion = appPrefs.getInt(PREFS_VERSION_KEY, 0); + Log.d(LOGTAG, "Current version = " + currentVersion + ", prefs version = " + PREFS_VERSION); + + if (currentVersion == PREFS_VERSION) { + return; + } + + Log.d(LOGTAG, "Performing migration"); + + final Editor appEditor = appPrefs.edit(); + + // The migration always moves prefs to the default profile, not + // the current one. We might have to revisit this if we ever support + // multiple profiles. + final String defaultProfileName; + try { + defaultProfileName = GeckoProfile.getDefaultProfileName(context); + } catch (Exception e) { + throw new IllegalStateException("Failed to get default profile name for migration"); + } + + final Editor profileEditor = forProfileName(context, defaultProfileName, disableMigrations).edit(); + final Editor crashEditor = forCrashReporter(context, disableMigrations).edit(); + + List<String> profileKeys; + Editor pmEditor = null; + + for (int v = currentVersion + 1; v <= PREFS_VERSION; v++) { + Log.d(LOGTAG, "Migrating to version = " + v); + + switch (v) { + case 1: + profileKeys = Arrays.asList(PROFILE_MIGRATIONS_0_TO_1); + pmEditor = migrateFromPreferenceManager(context, appEditor, profileEditor, profileKeys); + break; + case 2: + profileKeys = Arrays.asList(PROFILE_MIGRATIONS_1_TO_2); + migrateCrashReporterSettings(appPrefs, appEditor, crashEditor, profileKeys); + break; + } + } + + // Update prefs version accordingly. + appEditor.putInt(PREFS_VERSION_KEY, PREFS_VERSION); + + appEditor.apply(); + profileEditor.apply(); + crashEditor.apply(); + if (pmEditor != null) { + pmEditor.apply(); + } + + Log.d(LOGTAG, "All keys have been migrated"); + } + + /** + * Moves all preferences stored in PreferenceManager's default prefs + * to either app or profile scopes. The profile-scoped keys are defined + * in given profileKeys list, all other keys are moved to the app scope. + */ + public static Editor migrateFromPreferenceManager(Context context, Editor appEditor, + Editor profileEditor, List<String> profileKeys) { + Log.d(LOGTAG, "Migrating from PreferenceManager"); + + final SharedPreferences pmPrefs = + PreferenceManager.getDefaultSharedPreferences(context); + + for (Map.Entry<String, ?> entry : pmPrefs.getAll().entrySet()) { + final String key = entry.getKey(); + + final Editor to; + if (profileKeys.contains(key)) { + to = profileEditor; + } else { + to = appEditor; + } + + putEntry(to, key, entry.getValue()); + } + + // Clear PreferenceManager's prefs once we're done + // and return the Editor to be committed. + return pmPrefs.edit().clear(); + } + + /** + * Moves the crash reporter's preferences from the app-wide prefs + * into its own shared prefs to avoid cross-process pref accesses. + */ + public static void migrateCrashReporterSettings(SharedPreferences appPrefs, Editor appEditor, + Editor crashEditor, List<String> profileKeys) { + Log.d(LOGTAG, "Migrating crash reporter settings"); + + for (Map.Entry<String, ?> entry : appPrefs.getAll().entrySet()) { + final String key = entry.getKey(); + + if (profileKeys.contains(key)) { + putEntry(crashEditor, key, entry.getValue()); + appEditor.remove(key); + } + } + } + + private static void putEntry(Editor to, String key, Object value) { + Log.d(LOGTAG, "Migrating key = " + key + " with value = " + value); + + if (value instanceof String) { + to.putString(key, (String) value); + } else if (value instanceof Boolean) { + to.putBoolean(key, (Boolean) value); + } else if (value instanceof Long) { + to.putLong(key, (Long) value); + } else if (value instanceof Float) { + to.putFloat(key, (Float) value); + } else if (value instanceof Integer) { + to.putInt(key, (Integer) value); + } else { + throw new IllegalStateException("Unrecognized value type for key: " + key); + } + } +}
\ No newline at end of file diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java new file mode 100644 index 000000000..b57222a31 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java @@ -0,0 +1,677 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.os.SystemClock; +import android.util.Log; + +import java.io.File; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Locale; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public class GeckoThread extends Thread { + private static final String LOGTAG = "GeckoThread"; + + public enum State { + // After being loaded by class loader. + @WrapForJNI INITIAL(0), + // After launching Gecko thread + @WrapForJNI LAUNCHED(1), + // After loading the mozglue library. + @WrapForJNI MOZGLUE_READY(2), + // After loading the libxul library. + @WrapForJNI LIBS_READY(3), + // After initializing nsAppShell and JNI calls. + @WrapForJNI JNI_READY(4), + // After initializing profile and prefs. + @WrapForJNI PROFILE_READY(5), + // After initializing frontend JS + @WrapForJNI RUNNING(6), + // After leaving Gecko event loop + @WrapForJNI EXITING(3), + // After exiting GeckoThread (corresponding to "Gecko:Exited" event) + @WrapForJNI EXITED(0); + + /* The rank is an arbitrary value reflecting the amount of components or features + * that are available for use. During startup and up to the RUNNING state, the + * rank value increases because more components are initialized and available for + * use. During shutdown and up to the EXITED state, the rank value decreases as + * components are shut down and become unavailable. EXITING has the same rank as + * LIBS_READY because both states have a similar amount of components available. + */ + private final int rank; + + private State(int rank) { + this.rank = rank; + } + + public boolean is(final State other) { + return this == other; + } + + public boolean isAtLeast(final State other) { + return this.rank >= other.rank; + } + + public boolean isAtMost(final State other) { + return this.rank <= other.rank; + } + + // Inclusive + public boolean isBetween(final State min, final State max) { + return this.rank >= min.rank && this.rank <= max.rank; + } + } + + public static final State MIN_STATE = State.INITIAL; + public static final State MAX_STATE = State.EXITED; + + private static volatile State sState = State.INITIAL; + + private static class QueuedCall { + public Method method; + public Object target; + public Object[] args; + public State state; + + public QueuedCall(final Method method, final Object target, + final Object[] args, final State state) { + this.method = method; + this.target = target; + this.args = args; + this.state = state; + } + } + + private static final int QUEUED_CALLS_COUNT = 16; + private static final ArrayList<QueuedCall> QUEUED_CALLS = new ArrayList<>(QUEUED_CALLS_COUNT); + + private static final Runnable UI_THREAD_CALLBACK = new Runnable() { + @Override + public void run() { + ThreadUtils.assertOnUiThread(); + long nextDelay = runUiThreadCallback(); + if (nextDelay >= 0) { + ThreadUtils.getUiHandler().postDelayed(this, nextDelay); + } + } + }; + + private static GeckoThread sGeckoThread; + + @WrapForJNI + private static final ClassLoader clsLoader = GeckoThread.class.getClassLoader(); + @WrapForJNI + private static MessageQueue msgQueue; + + private GeckoProfile mProfile; + + private final String mArgs; + private final String mAction; + private final boolean mDebugging; + + GeckoThread(GeckoProfile profile, String args, String action, boolean debugging) { + mProfile = profile; + mArgs = args; + mAction = action; + mDebugging = debugging; + + setName("Gecko"); + } + + public static boolean init(GeckoProfile profile, String args, String action, boolean debugging) { + ThreadUtils.assertOnUiThread(); + if (isState(State.INITIAL) && sGeckoThread == null) { + sGeckoThread = new GeckoThread(profile, args, action, debugging); + return true; + } + return false; + } + + private static boolean canUseProfile(final Context context, final GeckoProfile profile, + final String profileName, final File profileDir) { + if (profileDir != null && !profileDir.isDirectory()) { + return false; + } + + if (profile == null) { + // We haven't initialized; any profile is okay as long as we follow the guest mode setting. + return GeckoProfile.shouldUseGuestMode(context) == + GeckoProfile.isGuestProfile(context, profileName, profileDir); + } + + // We already initialized and have a profile; see if it matches ours. + try { + return profileDir == null ? profileName.equals(profile.getName()) : + profile.getDir().getCanonicalPath().equals(profileDir.getCanonicalPath()); + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot compare profile " + profileName); + return false; + } + } + + public static boolean canUseProfile(final String profileName, final File profileDir) { + if (profileName == null) { + throw new IllegalArgumentException("Null profile name"); + } + return canUseProfile(GeckoAppShell.getApplicationContext(), getActiveProfile(), + profileName, profileDir); + } + + public static boolean initWithProfile(final String profileName, final File profileDir) { + if (profileName == null) { + throw new IllegalArgumentException("Null profile name"); + } + + final Context context = GeckoAppShell.getApplicationContext(); + final GeckoProfile profile = getActiveProfile(); + + if (!canUseProfile(context, profile, profileName, profileDir)) { + // Profile is incompatible with current profile. + return false; + } + + if (profile != null) { + // We already have a compatible profile. + return true; + } + + // We haven't initialized yet; okay to initialize now. + return init(GeckoProfile.get(context, profileName, profileDir), + /* args */ null, /* action */ null, /* debugging */ false); + } + + public static boolean launch() { + ThreadUtils.assertOnUiThread(); + if (checkAndSetState(State.INITIAL, State.LAUNCHED)) { + sGeckoThread.start(); + return true; + } + return false; + } + + public static boolean isLaunched() { + return !isState(State.INITIAL); + } + + @RobocopTarget + public static boolean isRunning() { + return isState(State.RUNNING); + } + + // Invoke the given Method and handle checked Exceptions. + private static void invokeMethod(final Method method, final Object obj, final Object[] args) { + try { + method.setAccessible(true); + method.invoke(obj, args); + } catch (final IllegalAccessException e) { + throw new IllegalStateException("Unexpected exception", e); + } catch (final InvocationTargetException e) { + throw new UnsupportedOperationException("Cannot make call", e.getCause()); + } + } + + // Queue a call to the given method. + private static void queueNativeCallLocked(final Class<?> cls, final String methodName, + final Object obj, final Object[] args, + final State state) { + final ArrayList<Class<?>> argTypes = new ArrayList<>(args.length); + final ArrayList<Object> argValues = new ArrayList<>(args.length); + + for (int i = 0; i < args.length; i++) { + if (args[i] instanceof Class) { + argTypes.add((Class<?>) args[i]); + argValues.add(args[++i]); + continue; + } + Class<?> argType = args[i].getClass(); + if (argType == Boolean.class) argType = Boolean.TYPE; + else if (argType == Byte.class) argType = Byte.TYPE; + else if (argType == Character.class) argType = Character.TYPE; + else if (argType == Double.class) argType = Double.TYPE; + else if (argType == Float.class) argType = Float.TYPE; + else if (argType == Integer.class) argType = Integer.TYPE; + else if (argType == Long.class) argType = Long.TYPE; + else if (argType == Short.class) argType = Short.TYPE; + argTypes.add(argType); + argValues.add(args[i]); + } + final Method method; + try { + method = cls.getDeclaredMethod( + methodName, argTypes.toArray(new Class<?>[argTypes.size()])); + } catch (final NoSuchMethodException e) { + throw new IllegalArgumentException("Cannot find method", e); + } + + if (!Modifier.isNative(method.getModifiers())) { + // As a precaution, we disallow queuing non-native methods. Queuing non-native + // methods is dangerous because the method could end up being called on either + // the original thread or the Gecko thread depending on timing. Native methods + // usually handle this by posting an event to the Gecko thread automatically, + // but there is no automatic mechanism for non-native methods. + throw new UnsupportedOperationException("Not allowed to queue non-native methods"); + } + + if (isStateAtLeast(state)) { + invokeMethod(method, obj, argValues.toArray()); + return; + } + + QUEUED_CALLS.add(new QueuedCall( + method, obj, argValues.toArray(), state)); + } + + /** + * Queue a call to the given static method until Gecko is in the given state. + * + * @param state The Gecko state in which the native call could be executed. + * Default is State.RUNNING, which means this queued call will + * run when Gecko is at or after RUNNING state. + * @param cls Class that declares the static method. + * @param methodName Name of the static method. + * @param args Args to call the static method with; to specify a parameter type, + * pass in a Class instance first, followed by the value. + */ + public static void queueNativeCallUntil(final State state, final Class<?> cls, + final String methodName, final Object... args) { + synchronized (QUEUED_CALLS) { + queueNativeCallLocked(cls, methodName, null, args, state); + } + } + + /** + * Queue a call to the given static method until Gecko is in the RUNNING state. + */ + public static void queueNativeCall(final Class<?> cls, final String methodName, + final Object... args) { + synchronized (QUEUED_CALLS) { + queueNativeCallLocked(cls, methodName, null, args, State.RUNNING); + } + } + + /** + * Queue a call to the given instance method until Gecko is in the given state. + * + * @param state The Gecko state in which the native call could be executed. + * @param obj Object that declares the instance method. + * @param methodName Name of the instance method. + * @param args Args to call the instance method with; to specify a parameter type, + * pass in a Class instance first, followed by the value. + */ + public static void queueNativeCallUntil(final State state, final Object obj, + final String methodName, final Object... args) { + synchronized (QUEUED_CALLS) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, state); + } + } + + /** + * Queue a call to the given instance method until Gecko is in the RUNNING state. + */ + public static void queueNativeCall(final Object obj, final String methodName, + final Object... args) { + synchronized (QUEUED_CALLS) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, State.RUNNING); + } + } + + // Run all queued methods + private static void flushQueuedNativeCallsLocked(final State state) { + int lastSkipped = -1; + for (int i = 0; i < QUEUED_CALLS.size(); i++) { + final QueuedCall call = QUEUED_CALLS.get(i); + if (call == null) { + // We already handled the call. + continue; + } + if (!state.isAtLeast(call.state)) { + // The call is not ready yet; skip it. + lastSkipped = i; + continue; + } + // Mark as handled. + QUEUED_CALLS.set(i, null); + + invokeMethod(call.method, call.target, call.args); + } + if (lastSkipped < 0) { + // We're done here; release the memory + QUEUED_CALLS.clear(); + QUEUED_CALLS.trimToSize(); + } else if (lastSkipped < QUEUED_CALLS.size() - 1) { + // We skipped some; free up null entries at the end, + // but keep all the previous entries for later. + QUEUED_CALLS.subList(lastSkipped + 1, QUEUED_CALLS.size()).clear(); + } + } + + private static String initGeckoEnvironment() { + final Context context = GeckoAppShell.getApplicationContext(); + GeckoLoader.loadMozGlue(context); + setState(State.MOZGLUE_READY); + + final Locale locale = Locale.getDefault(); + final Resources res = context.getResources(); + if (locale.toString().equalsIgnoreCase("zh_hk")) { + final Locale mappedLocale = Locale.TRADITIONAL_CHINESE; + Locale.setDefault(mappedLocale); + Configuration config = res.getConfiguration(); + config.locale = mappedLocale; + res.updateConfiguration(config, null); + } + + String[] pluginDirs = null; + try { + pluginDirs = GeckoAppShell.getPluginDirectories(); + } catch (Exception e) { + Log.w(LOGTAG, "Caught exception getting plugin dirs.", e); + } + + final String resourcePath = context.getPackageResourcePath(); + GeckoLoader.setupGeckoEnvironment(context, pluginDirs, context.getFilesDir().getPath()); + + GeckoLoader.loadSQLiteLibs(context, resourcePath); + GeckoLoader.loadNSSLibs(context, resourcePath); + GeckoLoader.loadGeckoLibs(context, resourcePath); + setState(State.LIBS_READY); + + return resourcePath; + } + + private String addCustomProfileArg(String args) { + String profileArg = ""; + + // Make sure a profile exists. + final GeckoProfile profile = getProfile(); + profile.getDir(); // call the lazy initializer + + // If args don't include the profile, make sure it's included. + if (args == null || !args.matches(".*\\B-(P|profile)\\s+\\S+.*")) { + if (profile.isCustomProfile()) { + profileArg = " -profile " + profile.getDir().getAbsolutePath(); + } else { + profileArg = " -P " + profile.getName(); + } + } + + return (args != null ? args : "") + profileArg; + } + + private String getGeckoArgs(final String apkPath) { + // argv[0] is the program name, which for us is the package name. + final Context context = GeckoAppShell.getApplicationContext(); + final StringBuilder args = new StringBuilder(context.getPackageName()); + args.append(" -greomni ").append(apkPath); + + final String userArgs = addCustomProfileArg(mArgs); + if (userArgs != null) { + args.append(' ').append(userArgs); + } + + // In un-official builds, we want to load Javascript resources fresh + // with each build. In official builds, the startup cache is purged by + // the buildid mechanism, but most un-official builds don't bump the + // buildid, so we purge here instead. + if (!AppConstants.MOZILLA_OFFICIAL) { + Log.w(LOGTAG, "STARTUP PERFORMANCE WARNING: un-official build: purging the " + + "startup (JavaScript) caches."); + args.append(" -purgecaches"); + } + + return args.toString(); + } + + public static GeckoProfile getActiveProfile() { + if (sGeckoThread == null) { + return null; + } + final GeckoProfile profile = sGeckoThread.mProfile; + if (profile != null) { + return profile; + } + return sGeckoThread.getProfile(); + } + + public synchronized GeckoProfile getProfile() { + if (mProfile == null) { + final Context context = GeckoAppShell.getApplicationContext(); + mProfile = GeckoProfile.initFromArgs(context, mArgs); + } + return mProfile; + } + + @Override + public void run() { + Log.i(LOGTAG, "preparing to run Gecko"); + + Looper.prepare(); + GeckoThread.msgQueue = Looper.myQueue(); + ThreadUtils.sGeckoThread = this; + ThreadUtils.sGeckoHandler = new Handler(); + + // Preparation for pumpMessageLoop() + final MessageQueue.IdleHandler idleHandler = new MessageQueue.IdleHandler() { + @Override public boolean queueIdle() { + final Handler geckoHandler = ThreadUtils.sGeckoHandler; + Message idleMsg = Message.obtain(geckoHandler); + // Use |Message.obj == GeckoHandler| to identify our "queue is empty" message + idleMsg.obj = geckoHandler; + geckoHandler.sendMessageAtFrontOfQueue(idleMsg); + // Keep this IdleHandler + return true; + } + }; + Looper.myQueue().addIdleHandler(idleHandler); + + if (mDebugging) { + try { + Thread.sleep(5 * 1000 /* 5 seconds */); + } catch (final InterruptedException e) { + } + } + + final String args = getGeckoArgs(initGeckoEnvironment()); + + // This can only happen after the call to initGeckoEnvironment + // above, because otherwise the JNI code hasn't been loaded yet. + ThreadUtils.postToUiThread(new Runnable() { + @Override public void run() { + registerUiThread(); + } + }); + + Log.w(LOGTAG, "zerdatime " + SystemClock.uptimeMillis() + " - runGecko"); + + if (!AppConstants.MOZILLA_OFFICIAL) { + Log.i(LOGTAG, "RunGecko - args = " + args); + } + + // And go. + GeckoLoader.nativeRun(args); + + // And... we're done. + setState(State.EXITED); + + try { + final JSONObject msg = new JSONObject(); + msg.put("type", "Gecko:Exited"); + GeckoAppShell.getGeckoInterface().getAppEventDispatcher().dispatchEvent(msg, null); + EventDispatcher.getInstance().dispatchEvent(msg, null); + } catch (final JSONException e) { + Log.e(LOGTAG, "unable to dispatch event", e); + } + + // Remove pumpMessageLoop() idle handler + Looper.myQueue().removeIdleHandler(idleHandler); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean pumpMessageLoop(final Message msg) { + final Handler geckoHandler = ThreadUtils.sGeckoHandler; + + if (msg.obj == geckoHandler && msg.getTarget() == geckoHandler) { + // Our "queue is empty" message; see runGecko() + return false; + } + + if (msg.getTarget() == null) { + Looper.myLooper().quit(); + } else { + msg.getTarget().dispatchMessage(msg); + } + + return true; + } + + /** + * Check that the current Gecko thread state matches the given state. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isState(final State state) { + return sState.is(state); + } + + /** + * Check that the current Gecko thread state is at the given state or further along, + * according to the order defined in the State enum. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isStateAtLeast(final State state) { + return sState.isAtLeast(state); + } + + /** + * Check that the current Gecko thread state is at the given state or prior, + * according to the order defined in the State enum. + * + * @param state State to check + * @return True if the current Gecko thread state matches + */ + public static boolean isStateAtMost(final State state) { + return sState.isAtMost(state); + } + + /** + * Check that the current Gecko thread state falls into an inclusive range of states, + * according to the order defined in the State enum. + * + * @param minState Lower range of allowable states + * @param maxState Upper range of allowable states + * @return True if the current Gecko thread state matches + */ + public static boolean isStateBetween(final State minState, final State maxState) { + return sState.isBetween(minState, maxState); + } + + @WrapForJNI(calledFrom = "gecko") + private static void setState(final State newState) { + ThreadUtils.assertOnGeckoThread(); + synchronized (QUEUED_CALLS) { + flushQueuedNativeCallsLocked(newState); + sState = newState; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean checkAndSetState(final State currentState, final State newState) { + synchronized (QUEUED_CALLS) { + if (sState == currentState) { + flushQueuedNativeCallsLocked(newState); + sState = newState; + return true; + } + } + return false; + } + + @WrapForJNI(stubName = "SpeculativeConnect") + private static native void speculativeConnectNative(String uri); + + public static void speculativeConnect(final String uri) { + // This is almost always called before Gecko loads, so we don't + // bother checking here if Gecko is actually loaded or not. + // Speculative connection depends on proxy settings, + // so the earliest it can happen is after profile is ready. + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, + "speculativeConnectNative", uri); + } + + @WrapForJNI @RobocopTarget + public static native void waitOnGecko(); + + @WrapForJNI(stubName = "OnPause", dispatchTo = "gecko") + private static native void nativeOnPause(); + + public static void onPause() { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeOnPause(); + } else { + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, + "nativeOnPause"); + } + } + + @WrapForJNI(stubName = "OnResume", dispatchTo = "gecko") + private static native void nativeOnResume(); + + public static void onResume() { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeOnResume(); + } else { + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, + "nativeOnResume"); + } + } + + @WrapForJNI(stubName = "CreateServices", dispatchTo = "gecko") + private static native void nativeCreateServices(String category, String data); + + public static void createServices(final String category, final String data) { + if (isStateAtLeast(State.PROFILE_READY)) { + nativeCreateServices(category, data); + } else { + queueNativeCallUntil(State.PROFILE_READY, GeckoThread.class, "nativeCreateServices", + String.class, category, String.class, data); + } + } + + // Implemented in mozglue/android/APKOpen.cpp. + /* package */ static native void registerUiThread(); + + @WrapForJNI(calledFrom = "ui") + /* package */ static native long runUiThreadCallback(); + + @WrapForJNI + private static void requestUiThreadCallback(long delay) { + ThreadUtils.getUiHandler().postDelayed(UI_THREAD_CALLBACK, delay); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java new file mode 100644 index 000000000..93d738361 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoView.java @@ -0,0 +1,736 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * vim: ts=4 sw=4 expandtab: + * 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 java.util.Set; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.LayerView; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoEventListener; +import org.mozilla.gecko.util.NativeEventListener; +import org.mozilla.gecko.util.NativeJSObject; +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Binder; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +public class GeckoView extends LayerView + implements ContextGetter, GeckoEventListener, NativeEventListener { + + private static final String DEFAULT_SHARED_PREFERENCES_FILE = "GeckoView"; + private static final String LOGTAG = "GeckoView"; + + private ChromeDelegate mChromeDelegate; + private ContentDelegate mContentDelegate; + + private InputConnectionListener mInputConnectionListener; + + protected boolean onAttachedToWindowCalled; + protected String chromeURI = getGeckoInterface().getDefaultChromeURI(); + protected int screenId = 0; // default to the primary screen + + @Override + public void handleMessage(final String event, final JSONObject message) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + try { + if (event.equals("Gecko:Ready")) { + handleReady(message); + } else if (event.equals("Content:StateChange")) { + handleStateChange(message); + } else if (event.equals("Content:LoadError")) { + handleLoadError(message); + } else if (event.equals("Content:PageShow")) { + handlePageShow(message); + } else if (event.equals("DOMTitleChanged")) { + handleTitleChanged(message); + } else if (event.equals("Link:Favicon")) { + handleLinkFavicon(message); + } else if (event.equals("Prompt:Show") || event.equals("Prompt:ShowTop")) { + handlePrompt(message); + } else if (event.equals("Accessibility:Event")) { + int mode = getImportantForAccessibility(); + if (mode == View.IMPORTANT_FOR_ACCESSIBILITY_YES || + mode == View.IMPORTANT_FOR_ACCESSIBILITY_AUTO) { + GeckoAccessibility.sendAccessibilityEvent(message); + } + } + } catch (Exception e) { + Log.e(LOGTAG, "handleMessage threw for " + event, e); + } + } + }); + } + + @Override + public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) { + try { + if ("Accessibility:Ready".equals(event)) { + GeckoAccessibility.updateAccessibilitySettings(getContext()); + } else if ("GeckoView:Message".equals(event)) { + // We need to pull out the bundle while on the Gecko thread. + NativeJSObject json = message.optObject("data", null); + if (json == null) { + // Must have payload to call the message handler. + return; + } + final Bundle data = json.toBundle(); + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + handleScriptMessage(data, callback); + } + }); + } + } catch (Exception e) { + Log.w(LOGTAG, "handleMessage threw for " + event, e); + } + } + + @WrapForJNI(dispatchTo = "proxy") + protected static final class Window extends JNIObject { + @WrapForJNI(skip = true) + /* package */ Window() {} + + static native void open(Window instance, GeckoView view, Object compositor, + String chromeURI, int screenId); + + @Override protected native void disposeNative(); + native void close(); + native void reattach(GeckoView view, Object compositor); + native void loadUri(String uri, int flags); + } + + // Object to hold onto our nsWindow connection when GeckoView gets destroyed. + private static class StateBinder extends Binder implements Parcelable { + public final Parcelable superState; + public final Window window; + + public StateBinder(Parcelable superState, Window window) { + this.superState = superState; + this.window = window; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel out, int flags) { + // Always write out the super-state, so that even if we lose this binder, we + // will still have something to pass into super.onRestoreInstanceState. + out.writeParcelable(superState, flags); + out.writeStrongBinder(this); + } + + @ReflectionTarget + public static final Parcelable.Creator<StateBinder> CREATOR + = new Parcelable.Creator<StateBinder>() { + @Override + public StateBinder createFromParcel(Parcel in) { + final Parcelable superState = in.readParcelable(null); + final IBinder binder = in.readStrongBinder(); + if (binder instanceof StateBinder) { + return (StateBinder) binder; + } + // Not the original object we saved; return null state. + return new StateBinder(superState, null); + } + + @Override + public StateBinder[] newArray(int size) { + return new StateBinder[size]; + } + }; + } + + protected Window window; + private boolean stateSaved; + + public GeckoView(Context context) { + super(context); + init(context); + } + + public GeckoView(Context context, AttributeSet attrs) { + super(context, attrs); + init(context); + } + + private void init(Context context) { + if (GeckoAppShell.getApplicationContext() == null) { + GeckoAppShell.setApplicationContext(context.getApplicationContext()); + } + + // Set the GeckoInterface if the context is an activity and the GeckoInterface + // has not already been set + if (context instanceof Activity && getGeckoInterface() == null) { + setGeckoInterface(new BaseGeckoInterface(context)); + GeckoAppShell.setContextGetter(this); + } + + // Perform common initialization for Fennec/GeckoView. + GeckoAppShell.setLayerView(this); + + initializeView(EventDispatcher.getInstance()); + } + + @Override + protected Parcelable onSaveInstanceState() + { + final Parcelable superState = super.onSaveInstanceState(); + stateSaved = true; + return new StateBinder(superState, this.window); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) + { + final StateBinder stateBinder = (StateBinder) state; + + if (stateBinder.window != null) { + this.window = stateBinder.window; + } + stateSaved = false; + + if (onAttachedToWindowCalled) { + reattachWindow(); + } + + // We have to always call super.onRestoreInstanceState because View keeps + // track of these calls and throws an exception when we don't call it. + super.onRestoreInstanceState(stateBinder.superState); + } + + protected void openWindow() { + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + Window.open(window, this, getCompositor(), + chromeURI, screenId); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, Window.class, + "open", window, GeckoView.class, this, Object.class, getCompositor(), + String.class, chromeURI, screenId); + } + } + + protected void reattachWindow() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + window.reattach(this, getCompositor()); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, + window, "reattach", GeckoView.class, this, Object.class, getCompositor()); + } + } + + @Override + public void onAttachedToWindow() + { + final DisplayMetrics metrics = getContext().getResources().getDisplayMetrics(); + + if (window == null) { + // Open a new nsWindow if we didn't have one from before. + window = new Window(); + openWindow(); + } else { + reattachWindow(); + } + + super.onAttachedToWindow(); + + onAttachedToWindowCalled = true; + } + + @Override + public void onDetachedFromWindow() + { + super.onDetachedFromWindow(); + super.destroy(); + + if (stateSaved) { + // If we saved state earlier, we don't want to close the nsWindow. + return; + } + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + window.close(); + window.disposeNative(); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, + window, "close"); + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, + window, "disposeNative"); + } + + onAttachedToWindowCalled = false; + } + + @WrapForJNI public static final int LOAD_DEFAULT = 0; + @WrapForJNI public static final int LOAD_NEW_TAB = 1; + @WrapForJNI public static final int LOAD_SWITCH_TAB = 2; + + public void loadUri(String uri, int flags) { + if (window == null) { + throw new IllegalStateException("Not attached to window"); + } + + if (GeckoThread.isRunning()) { + window.loadUri(uri, flags); + } else { + GeckoThread.queueNativeCall(window, "loadUri", String.class, uri, flags); + } + } + + /* package */ void setInputConnectionListener(final InputConnectionListener icl) { + mInputConnectionListener = icl; + } + + @Override + public Handler getHandler() { + if (mInputConnectionListener != null) { + return mInputConnectionListener.getHandler(super.getHandler()); + } + return super.getHandler(); + } + + @Override + public InputConnection onCreateInputConnection(EditorInfo outAttrs) { + if (mInputConnectionListener != null) { + return mInputConnectionListener.onCreateInputConnection(outAttrs); + } + return null; + } + + @Override + public boolean onKeyPreIme(int keyCode, KeyEvent event) { + if (super.onKeyPreIme(keyCode, event)) { + return true; + } + return mInputConnectionListener != null && + mInputConnectionListener.onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent event) { + if (super.onKeyUp(keyCode, event)) { + return true; + } + return mInputConnectionListener != null && + mInputConnectionListener.onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + if (super.onKeyDown(keyCode, event)) { + return true; + } + return mInputConnectionListener != null && + mInputConnectionListener.onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(int keyCode, KeyEvent event) { + if (super.onKeyLongPress(keyCode, event)) { + return true; + } + return mInputConnectionListener != null && + mInputConnectionListener.onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event) { + if (super.onKeyMultiple(keyCode, repeatCount, event)) { + return true; + } + return mInputConnectionListener != null && + mInputConnectionListener.onKeyMultiple(keyCode, repeatCount, event); + } + + /* package */ boolean isIMEEnabled() { + return mInputConnectionListener != null && + mInputConnectionListener.isIMEEnabled(); + } + + public void importScript(final String url) { + if (url.startsWith("resource://android/assets/")) { + GeckoAppShell.notifyObservers("GeckoView:ImportScript", url); + return; + } + + throw new IllegalArgumentException("Must import script from 'resources://android/assets/' location."); + } + + private void handleReady(final JSONObject message) { + if (mChromeDelegate != null) { + mChromeDelegate.onReady(this); + } + } + + private void handleStateChange(final JSONObject message) throws JSONException { + int state = message.getInt("state"); + if ((state & GeckoAppShell.WPL_STATE_IS_NETWORK) != 0) { + if ((state & GeckoAppShell.WPL_STATE_START) != 0) { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onPageStart(this, new Browser(id), message.getString("uri")); + } + } else if ((state & GeckoAppShell.WPL_STATE_STOP) != 0) { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onPageStop(this, new Browser(id), message.getBoolean("success")); + } + } + } + } + + private void handleLoadError(final JSONObject message) throws JSONException { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onPageStop(GeckoView.this, new Browser(id), false); + } + } + + private void handlePageShow(final JSONObject message) throws JSONException { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onPageShow(GeckoView.this, new Browser(id)); + } + } + + private void handleTitleChanged(final JSONObject message) throws JSONException { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onReceivedTitle(GeckoView.this, new Browser(id), message.getString("title")); + } + } + + private void handleLinkFavicon(final JSONObject message) throws JSONException { + if (mContentDelegate != null) { + int id = message.getInt("tabID"); + mContentDelegate.onReceivedFavicon(GeckoView.this, new Browser(id), message.getString("href"), message.getInt("size")); + } + } + + private void handlePrompt(final JSONObject message) throws JSONException { + if (mChromeDelegate != null) { + String hint = message.optString("hint"); + if ("alert".equals(hint)) { + String text = message.optString("text"); + mChromeDelegate.onAlert(GeckoView.this, null, text, new PromptResult(message)); + } else if ("confirm".equals(hint)) { + String text = message.optString("text"); + mChromeDelegate.onConfirm(GeckoView.this, null, text, new PromptResult(message)); + } else if ("prompt".equals(hint)) { + String text = message.optString("text"); + String defaultValue = message.optString("textbox0"); + mChromeDelegate.onPrompt(GeckoView.this, null, text, defaultValue, new PromptResult(message)); + } else if ("remotedebug".equals(hint)) { + mChromeDelegate.onDebugRequest(GeckoView.this, new PromptResult(message)); + } + } + } + + private void handleScriptMessage(final Bundle data, final EventCallback callback) { + if (mChromeDelegate != null) { + MessageResult result = null; + if (callback != null) { + result = new MessageResult(callback); + } + mChromeDelegate.onScriptMessage(GeckoView.this, data, result); + } + } + + /** + * Set the chrome callback handler. + * This will replace the current handler. + * @param chrome An implementation of GeckoViewChrome. + */ + public void setChromeDelegate(ChromeDelegate chrome) { + mChromeDelegate = chrome; + } + + /** + * Set the content callback handler. + * This will replace the current handler. + * @param content An implementation of ContentDelegate. + */ + public void setContentDelegate(ContentDelegate content) { + mContentDelegate = content; + } + + public static void setGeckoInterface(final BaseGeckoInterface geckoInterface) { + GeckoAppShell.setGeckoInterface(geckoInterface); + } + + public static GeckoAppShell.GeckoInterface getGeckoInterface() { + return GeckoAppShell.getGeckoInterface(); + } + + protected String getSharedPreferencesFile() { + return DEFAULT_SHARED_PREFERENCES_FILE; + } + + @Override + public SharedPreferences getSharedPreferences() { + return getContext().getSharedPreferences(getSharedPreferencesFile(), 0); + } + + /** + * Wrapper for a browser in the GeckoView container. Associated with a browser + * element in the Gecko system. + */ + public class Browser { + private final int mId; + private Browser(int Id) { + mId = Id; + } + + /** + * Get the ID of the Browser. This is the same ID used by Gecko for it's underlying + * browser element. + * @return The integer ID of the Browser. + */ + private int getId() { + return mId; + } + + /** + * Load a URL resource into the Browser. + * @param url The URL string. + */ + public void loadUrl(String url) { + JSONObject args = new JSONObject(); + try { + args.put("url", url); + args.put("parentId", -1); + args.put("newTab", false); + args.put("tabID", mId); + } catch (Exception e) { + Log.w(LOGTAG, "Error building JSON arguments for loadUrl.", e); + } + GeckoAppShell.notifyObservers("Tab:Load", args.toString()); + } + } + + /* Provides a means for the client to indicate whether a JavaScript + * dialog request should proceed. An instance of this class is passed to + * various GeckoViewChrome callback actions. + */ + public class PromptResult { + private final int RESULT_OK = 0; + private final int RESULT_CANCEL = 1; + + private final JSONObject mMessage; + + public PromptResult(JSONObject message) { + mMessage = message; + } + + private JSONObject makeResult(int resultCode) { + JSONObject result = new JSONObject(); + try { + result.put("button", resultCode); + } catch (JSONException ex) { } + return result; + } + + /** + * Handle a confirmation response from the user. + */ + public void confirm() { + JSONObject result = makeResult(RESULT_OK); + EventDispatcher.sendResponse(mMessage, result); + } + + /** + * Handle a confirmation response from the user. + * @param value String value to return to the browser context. + */ + public void confirmWithValue(String value) { + JSONObject result = makeResult(RESULT_OK); + try { + result.put("textbox0", value); + } catch (JSONException ex) { } + EventDispatcher.sendResponse(mMessage, result); + } + + /** + * Handle a cancellation response from the user. + */ + public void cancel() { + JSONObject result = makeResult(RESULT_CANCEL); + EventDispatcher.sendResponse(mMessage, result); + } + } + + /* Provides a means for the client to respond to a script message with some data. + * An instance of this class is passed to GeckoViewChrome.onScriptMessage. + */ + public class MessageResult { + private final EventCallback mCallback; + + public MessageResult(EventCallback callback) { + if (callback == null) { + throw new IllegalArgumentException("EventCallback should not be null."); + } + mCallback = callback; + } + + private JSONObject bundleToJSON(Bundle data) { + JSONObject result = new JSONObject(); + if (data == null) { + return result; + } + + final Set<String> keys = data.keySet(); + for (String key : keys) { + try { + result.put(key, data.get(key)); + } catch (JSONException e) { + } + } + return result; + } + + /** + * Handle a successful response to a script message. + * @param value Bundle value to return to the script context. + */ + public void success(Bundle data) { + mCallback.sendSuccess(bundleToJSON(data)); + } + + /** + * Handle a failure response to a script message. + */ + public void failure(Bundle data) { + mCallback.sendError(bundleToJSON(data)); + } + } + + public interface ChromeDelegate { + /** + * Tell the host application that Gecko is ready to handle requests. + * @param view The GeckoView that initiated the callback. + */ + public void onReady(GeckoView view); + + /** + * Tell the host application to display an alert dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + public void onAlert(GeckoView view, GeckoView.Browser browser, String message, GeckoView.PromptResult result); + + /** + * Tell the host application to display a confirmation dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + public void onConfirm(GeckoView view, GeckoView.Browser browser, String message, GeckoView.PromptResult result); + + /** + * Tell the host application to display an input prompt dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param defaultValue The string to use as default input. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + public void onPrompt(GeckoView view, GeckoView.Browser browser, String message, String defaultValue, GeckoView.PromptResult result); + + /** + * Tell the host application to display a remote debugging request dialog. + * @param view The GeckoView that initiated the callback. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + public void onDebugRequest(GeckoView view, GeckoView.PromptResult result); + + /** + * Receive a message from an imported script. + * @param view The GeckoView that initiated the callback. + * @param data Bundle of data sent with the message. Never null. + * @param result A MessageResult used to send back a response without blocking. Can be null. + * Defaults to do nothing. + */ + public void onScriptMessage(GeckoView view, Bundle data, GeckoView.MessageResult result); + } + + public interface ContentDelegate { + /** + * A Browser has started loading content from the network. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param url The resource being loaded. + */ + public void onPageStart(GeckoView view, GeckoView.Browser browser, String url); + + /** + * A Browser has finished loading content from the network. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that was loading the content. + * @param success Whether the page loaded successfully or an error occurred. + */ + public void onPageStop(GeckoView view, GeckoView.Browser browser, boolean success); + + /** + * A Browser is displaying content. This page could have been loaded via + * network or from the session history. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + */ + public void onPageShow(GeckoView view, GeckoView.Browser browser); + + /** + * A page title was discovered in the content or updated after the content + * loaded. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + * @param title The title sent from the content. + */ + public void onReceivedTitle(GeckoView view, GeckoView.Browser browser, String title); + + /** + * A link element was discovered in the content or updated after the content + * loaded that specifies a favicon. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + * @param url The href of the link element specifying the favicon. + * @param size The maximum size specified for the favicon, or -1 for any size. + */ + public void onReceivedFavicon(GeckoView view, GeckoView.Browser browser, String url, int size); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewChrome.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewChrome.java new file mode 100644 index 000000000..403c6dbca --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewChrome.java @@ -0,0 +1,81 @@ +/* -*- 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.os.Bundle; + +public class GeckoViewChrome implements GeckoView.ChromeDelegate { + /** + * Tell the host application that Gecko is ready to handle requests. + * @param view The GeckoView that initiated the callback. + */ + @Override + public void onReady(GeckoView view) {} + + /** + * Tell the host application to display an alert dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + @Override + public void onAlert(GeckoView view, GeckoView.Browser browser, String message, GeckoView.PromptResult result) { + result.cancel(); + } + + /** + * Tell the host application to display a confirmation dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + @Override + public void onConfirm(GeckoView view, GeckoView.Browser browser, String message, GeckoView.PromptResult result) { + result.cancel(); + } + + /** + * Tell the host application to display an input prompt dialog. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param message The string to display in the dialog. + * @param defaultValue The string to use as default input. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + @Override + public void onPrompt(GeckoView view, GeckoView.Browser browser, String message, String defaultValue, GeckoView.PromptResult result) { + result.cancel(); + } + + /** + * Tell the host application to display a remote debugging request dialog. + * @param view The GeckoView that initiated the callback. + * @param result A PromptResult used to send back the result without blocking. + * Defaults to cancel requests. + */ + @Override + public void onDebugRequest(GeckoView view, GeckoView.PromptResult result) { + result.cancel(); + } + + /** + * Receive a message from an imported script. + * @param view The GeckoView that initiated the callback. + * @param data Bundle of data sent with the message. Never null. + * @param result A MessageResult used to send back a response without blocking. Can be null. + * Defaults to cancel requests with a failed response. + */ + public void onScriptMessage(GeckoView view, Bundle data, GeckoView.MessageResult result) { + if (result != null) { + result.failure(null); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewContent.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewContent.java new file mode 100644 index 000000000..22d0ede75 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewContent.java @@ -0,0 +1,56 @@ +/* -*- 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; + +public class GeckoViewContent implements GeckoView.ContentDelegate { + /** + * A Browser has started loading content from the network. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is loading the content. + * @param url The resource being loaded. + */ + @Override + public void onPageStart(GeckoView view, GeckoView.Browser browser, String url) {} + + /** + * A Browser has finished loading content from the network. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that was loading the content. + * @param success Whether the page loaded successfully or an error occurred. + */ + @Override + public void onPageStop(GeckoView view, GeckoView.Browser browser, boolean success) {} + + /** + * A Browser is displaying content. This page could have been loaded via + * network or from the session history. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + */ + @Override + public void onPageShow(GeckoView view, GeckoView.Browser browser) {} + + /** + * A page title was discovered in the content or updated after the content + * loaded. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + * @param title The title sent from the content. + */ + @Override + public void onReceivedTitle(GeckoView view, GeckoView.Browser browser, String title) {} + + /** + * A link element was discovered in the content or updated after the content + * loaded that specifies a favicon. + * @param view The GeckoView that initiated the callback. + * @param browser The Browser that is showing the content. + * @param url The href of the link element specifying the favicon. + * @param size The maximum size specified for the favicon, or -1 for any size. + */ + @Override + public void onReceivedFavicon(GeckoView view, GeckoView.Browser browser, String url, int size) {} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewFragment.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewFragment.java new file mode 100644 index 000000000..51320636e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoViewFragment.java @@ -0,0 +1,52 @@ +/* -*- 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.support.v4.app.Fragment; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.Log; +import android.util.SparseArray; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +public class GeckoViewFragment extends android.support.v4.app.Fragment { + private static final String LOGTAG = "GeckoViewFragment"; + + private static Parcelable state = null; + private static GeckoViewFragment lastUsed = null; + private GeckoView geckoView = null; + + @Override + public void onCreate(Bundle savedInstanceState) { + setRetainInstance(true); + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + geckoView = new GeckoView(getContext()); + return geckoView; + } + + @Override + public void onResume() { + if (state != null && lastUsed != this) { + // "Restore" the window from the previously used GeckoView to this GeckoView and attach it + geckoView.onRestoreInstanceState(state); + state = null; + } + super.onResume(); + } + + @Override + public void onPause() { + state = geckoView.onSaveInstanceState(); + lastUsed = this; + super.onPause(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputConnectionListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputConnectionListener.java new file mode 100644 index 000000000..baddc4ed2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputConnectionListener.java @@ -0,0 +1,25 @@ +/* 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.os.Handler; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; + +/** + * Interface for interacting with GeckoInputConnection from GeckoView. + */ +interface InputConnectionListener +{ + Handler getHandler(Handler defHandler); + InputConnection onCreateInputConnection(EditorInfo outAttrs); + boolean onKeyPreIme(int keyCode, KeyEvent event); + boolean onKeyDown(int keyCode, KeyEvent event); + boolean onKeyLongPress(int keyCode, KeyEvent event); + boolean onKeyMultiple(int keyCode, int repeatCount, KeyEvent event); + boolean onKeyUp(int keyCode, KeyEvent event); + boolean isIMEEnabled(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java new file mode 100644 index 000000000..57649b0da --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java @@ -0,0 +1,76 @@ +/* -*- 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 java.util.Collection; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.content.Context; +import android.provider.Settings.Secure; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; + +final public class InputMethods { + public static final String METHOD_ANDROID_LATINIME = "com.android.inputmethod.latin/.LatinIME"; + public static final String METHOD_ATOK = "com.justsystems.atokmobile.service/.AtokInputMethodService"; + public static final String METHOD_GOOGLE_JAPANESE_INPUT = "com.google.android.inputmethod.japanese/.MozcService"; + public static final String METHOD_GOOGLE_LATINIME = "com.google.android.inputmethod.latin/com.android.inputmethod.latin.LatinIME"; + public static final String METHOD_HTC_TOUCH_INPUT = "com.htc.android.htcime/.HTCIMEService"; + public static final String METHOD_IWNN = "jp.co.omronsoft.iwnnime.ml/.standardcommon.IWnnLanguageSwitcher"; + public static final String METHOD_OPENWNN_PLUS = "com.owplus.ime.openwnnplus/.OpenWnnJAJP"; + public static final String METHOD_SAMSUNG = "com.sec.android.inputmethod/.SamsungKeypad"; + public static final String METHOD_SIMEJI = "com.adamrocker.android.input.simeji/.OpenWnnSimeji"; + public static final String METHOD_SWIFTKEY = "com.touchtype.swiftkey/com.touchtype.KeyboardService"; + public static final String METHOD_SWYPE = "com.swype.android.inputmethod/.SwypeInputMethod"; + public static final String METHOD_SWYPE_BETA = "com.nuance.swype.input/.IME"; + public static final String METHOD_TOUCHPAL_KEYBOARD = "com.cootek.smartinputv5/com.cootek.smartinput5.TouchPalIME"; + + private InputMethods() {} + + public static String getCurrentInputMethod(Context context) { + String inputMethod = Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD); + return (inputMethod != null ? inputMethod : ""); + } + + public static InputMethodInfo getInputMethodInfo(Context context, String inputMethod) { + InputMethodManager imm = getInputMethodManager(context); + Collection<InputMethodInfo> infos = imm.getEnabledInputMethodList(); + for (InputMethodInfo info : infos) { + if (info.getId().equals(inputMethod)) { + return info; + } + } + return null; + } + + public static InputMethodManager getInputMethodManager(Context context) { + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public static boolean needsSoftResetWorkaround(String inputMethod) { + // Stock latin IME on Android 4.2 and above + return Versions.feature17Plus && + (METHOD_ANDROID_LATINIME.equals(inputMethod) || + METHOD_GOOGLE_LATINIME.equals(inputMethod)); + } + + public static boolean shouldCommitCharAsKey(String inputMethod) { + return METHOD_HTC_TOUCH_INPUT.equals(inputMethod); + } + + public static boolean isGestureKeyboard(Context context) { + // SwiftKey is a gesture keyboard, but it doesn't seem to need any special-casing + // to do AwesomeBar auto-spacing. + String inputMethod = getCurrentInputMethod(context); + return (Versions.feature17Plus && + (METHOD_ANDROID_LATINIME.equals(inputMethod) || + METHOD_GOOGLE_LATINIME.equals(inputMethod))) || + METHOD_SWYPE.equals(inputMethod) || + METHOD_SWYPE_BETA.equals(inputMethod) || + METHOD_TOUCHPAL_KEYBOARD.equals(inputMethod); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NSSBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NSSBridge.java new file mode 100644 index 000000000..8d525b0ba --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NSSBridge.java @@ -0,0 +1,55 @@ +/* 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 org.mozilla.gecko.mozglue.GeckoLoader; + +import android.content.Context; +import org.mozilla.gecko.annotation.RobocopTarget; + +public class NSSBridge { + private static final String LOGTAG = "NSSBridge"; + + private static native String nativeEncrypt(String aDb, String aValue); + private static native String nativeDecrypt(String aDb, String aValue); + + @RobocopTarget + static public String encrypt(Context context, String aValue) + throws Exception { + String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadNSSLibs(context, resourcePath); + + String path = GeckoProfile.get(context).getDir().toString(); + return nativeEncrypt(path, aValue); + } + + @RobocopTarget + static public String encrypt(Context context, String profilePath, String aValue) + throws Exception { + String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadNSSLibs(context, resourcePath); + + return nativeEncrypt(profilePath, aValue); + } + + @RobocopTarget + static public String decrypt(Context context, String aValue) + throws Exception { + String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadNSSLibs(context, resourcePath); + + String path = GeckoProfile.get(context).getDir().toString(); + return nativeDecrypt(path, aValue); + } + + @RobocopTarget + static public String decrypt(Context context, String profilePath, String aValue) + throws Exception { + String resourcePath = context.getPackageResourcePath(); + GeckoLoader.loadNSSLibs(context, resourcePath); + + return nativeDecrypt(profilePath, aValue); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java new file mode 100644 index 000000000..85a68768f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NotificationListener.java @@ -0,0 +1,17 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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; + +public interface NotificationListener +{ + void showNotification(String name, String cookie, String title, String text, + String host, String imageUrl); + + void showPersistentNotification(String name, String cookie, String title, String text, + String host, String imageUrl, String data); + + void closeNotification(String name); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java new file mode 100644 index 000000000..b60f6fd88 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/PrefsHelper.java @@ -0,0 +1,308 @@ +/* -*- 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 org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; + +import android.support.v4.util.SimpleArrayMap; +import android.util.Log; +import android.util.SparseArray; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; + +/** + * Helper class to get/set gecko prefs. + */ +public final class PrefsHelper { + private static final String LOGTAG = "GeckoPrefsHelper"; + + // Map pref name to ArrayList for multiple observers or PrefHandler for single observer. + private static final SimpleArrayMap<String, Object> OBSERVERS = new SimpleArrayMap<>(); + private static final HashSet<String> INT_TO_STRING_PREFS = new HashSet<>(8); + private static final HashSet<String> INT_TO_BOOL_PREFS = new HashSet<>(2); + + static { + INT_TO_STRING_PREFS.add("browser.chrome.titlebarMode"); + INT_TO_STRING_PREFS.add("network.cookie.cookieBehavior"); + INT_TO_STRING_PREFS.add("font.size.inflation.minTwips"); + INT_TO_STRING_PREFS.add("home.sync.updateMode"); + INT_TO_STRING_PREFS.add("browser.image_blocking"); + INT_TO_BOOL_PREFS.add("browser.display.use_document_fonts"); + } + + @WrapForJNI + private static final int PREF_INVALID = -1; + @WrapForJNI + private static final int PREF_FINISH = 0; + @WrapForJNI + private static final int PREF_BOOL = 1; + @WrapForJNI + private static final int PREF_INT = 2; + @WrapForJNI + private static final int PREF_STRING = 3; + + @WrapForJNI(stubName = "GetPrefs", dispatchTo = "gecko") + private static native void nativeGetPrefs(String[] prefNames, PrefHandler handler); + @WrapForJNI(stubName = "SetPref", dispatchTo = "gecko") + private static native void nativeSetPref(String prefName, boolean flush, int type, + boolean boolVal, int intVal, String strVal); + @WrapForJNI(stubName = "AddObserver", dispatchTo = "gecko") + private static native void nativeAddObserver(String[] prefNames, PrefHandler handler, + String[] prefsToObserve); + @WrapForJNI(stubName = "RemoveObserver", dispatchTo = "gecko") + private static native void nativeRemoveObserver(String[] prefToUnobserve); + + @RobocopTarget + public static void getPrefs(final String[] prefNames, final PrefHandler callback) { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeGetPrefs(prefNames, callback); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeGetPrefs", + String[].class, prefNames, PrefHandler.class, callback); + } + } + + public static void getPref(final String prefName, final PrefHandler callback) { + getPrefs(new String[] { prefName }, callback); + } + + public static void getPrefs(final ArrayList<String> prefNames, final PrefHandler callback) { + getPrefs(prefNames.toArray(new String[prefNames.size()]), callback); + } + + @RobocopTarget + public static void setPref(final String pref, final Object value, final boolean flush) { + final int type; + boolean boolVal = false; + int intVal = 0; + String strVal = null; + + if (INT_TO_STRING_PREFS.contains(pref)) { + // When sending to Java, we normalized special preferences that use integers + // and strings to represent booleans. Here, we convert them back to their + // actual types so we can store them. + type = PREF_INT; + intVal = Integer.parseInt(String.valueOf(value)); + } else if (INT_TO_BOOL_PREFS.contains(pref)) { + type = PREF_INT; + intVal = (Boolean) value ? 1 : 0; + } else if (value instanceof Boolean) { + type = PREF_BOOL; + boolVal = (Boolean) value; + } else if (value instanceof Integer) { + type = PREF_INT; + intVal = (Integer) value; + } else { + type = PREF_STRING; + strVal = String.valueOf(value); + } + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeSetPref(pref, flush, type, boolVal, intVal, strVal); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeSetPref", + String.class, pref, flush, type, boolVal, intVal, String.class, strVal); + } + } + + public static void setPref(final String pref, final Object value) { + setPref(pref, value, /* flush */ false); + } + + @RobocopTarget + public synchronized static void addObserver(final String[] prefNames, + final PrefHandler handler) { + List<String> prefsToObserve = null; + + for (String pref : prefNames) { + final Object existing = OBSERVERS.get(pref); + + if (existing == null) { + // Not observing yet, so add observer. + if (prefsToObserve == null) { + prefsToObserve = new ArrayList<>(prefNames.length); + } + prefsToObserve.add(pref); + OBSERVERS.put(pref, handler); + + } else if (existing instanceof PrefHandler) { + // Already observing one, so turn it into an array. + final List<PrefHandler> handlerList = new ArrayList<>(2); + handlerList.add((PrefHandler) existing); + handlerList.add(handler); + OBSERVERS.put(pref, handlerList); + + } else { + // Already observing multiple, so add to existing array. + @SuppressWarnings("unchecked") + final List<PrefHandler> handlerList = (List) existing; + handlerList.add(handler); + } + } + + final String[] namesToObserve = prefsToObserve == null ? null : + prefsToObserve.toArray(new String[prefsToObserve.size()]); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeAddObserver(prefNames, handler, namesToObserve); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeAddObserver", + String[].class, prefNames, PrefHandler.class, handler, + String[].class, namesToObserve); + } + } + + @RobocopTarget + public synchronized static void removeObserver(final PrefHandler handler) { + List<String> prefsToUnobserve = null; + + for (int i = OBSERVERS.size() - 1; i >= 0; i--) { + final Object existing = OBSERVERS.valueAt(i); + boolean removeObserver = false; + + if (existing == handler) { + removeObserver = true; + + } else if (!(existing instanceof PrefHandler)) { + // Removing existing handler from list. + @SuppressWarnings("unchecked") + final List<PrefHandler> handlerList = (List) existing; + if (handlerList.remove(handler) && handlerList.isEmpty()) { + removeObserver = true; + } + } + + if (removeObserver) { + // Removed last handler, so remove observer. + if (prefsToUnobserve == null) { + prefsToUnobserve = new ArrayList<>(); + } + prefsToUnobserve.add(OBSERVERS.keyAt(i)); + OBSERVERS.removeAt(i); + } + } + + if (prefsToUnobserve == null) { + return; + } + + final String[] namesToUnobserve = + prefsToUnobserve.toArray(new String[prefsToUnobserve.size()]); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeRemoveObserver(namesToUnobserve); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, PrefsHelper.class, "nativeRemoveObserver", + String[].class, namesToUnobserve); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void callPrefHandler(final PrefHandler handler, int type, final String pref, + boolean boolVal, int intVal, String strVal) { + + // Some Gecko preferences use integers or strings to reference state instead of + // directly representing the value. Since the Java UI uses the type to determine + // which ui elements to show and how to handle them, we need to normalize these + // preferences to the correct type. + if (INT_TO_STRING_PREFS.contains(pref)) { + type = PREF_STRING; + strVal = String.valueOf(intVal); + } else if (INT_TO_BOOL_PREFS.contains(pref)) { + type = PREF_BOOL; + boolVal = intVal == 1; + } + + switch (type) { + case PREF_FINISH: + handler.finish(); + return; + case PREF_BOOL: + handler.prefValue(pref, boolVal); + return; + case PREF_INT: + handler.prefValue(pref, intVal); + return; + case PREF_STRING: + handler.prefValue(pref, strVal); + return; + } + throw new IllegalArgumentException(); + } + + @WrapForJNI(calledFrom = "gecko") + private synchronized static void onPrefChange(final String pref, final int type, + final boolean boolVal, final int intVal, + final String strVal) { + final Object existing = OBSERVERS.get(pref); + + if (existing == null) { + return; + } + + final Iterator<PrefHandler> itor; + PrefHandler handler; + + if (existing instanceof PrefHandler) { + itor = null; + handler = (PrefHandler) existing; + } else { + @SuppressWarnings("unchecked") + final List<PrefHandler> handlerList = (List) existing; + if (handlerList.isEmpty()) { + return; + } + itor = handlerList.iterator(); + handler = itor.next(); + } + + do { + callPrefHandler(handler, type, pref, boolVal, intVal, strVal); + handler.finish(); + + handler = itor != null && itor.hasNext() ? itor.next() : null; + } while (handler != null); + } + + public interface PrefHandler { + void prefValue(String pref, boolean value); + void prefValue(String pref, int value); + void prefValue(String pref, String value); + void finish(); + } + + public static abstract class PrefHandlerBase implements PrefHandler { + @Override + public void prefValue(String pref, boolean value) { + throw new UnsupportedOperationException( + "Unhandled boolean pref " + pref + "; wrong type?"); + } + + @Override + public void prefValue(String pref, int value) { + throw new UnsupportedOperationException( + "Unhandled int pref " + pref + "; wrong type?"); + } + + @Override + public void prefValue(String pref, String value) { + throw new UnsupportedOperationException( + "Unhandled String pref " + pref + "; wrong type?"); + } + + @Override + public void finish() { + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java new file mode 100644 index 000000000..5c53ef465 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SysInfo.java @@ -0,0 +1,237 @@ +/* -*- 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.os.StrictMode; +import android.util.Log; + +import java.io.File; +import java.io.FileFilter; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; + +import java.util.regex.Pattern; + +/** + * A collection of system info values, broadly mirroring a subset of + * nsSystemInfo. See also the constants in AppConstants, which reflect + * much of nsIXULAppInfo. + */ +// Normally, we'd annotate with @RobocopTarget. Since SysInfo is compiled +// before RobocopTarget, we instead add o.m.g.SysInfo directly to the Proguard +// configuration. +public final class SysInfo { + private static final String LOG_TAG = "GeckoSysInfo"; + + // Number of bytes of /proc/meminfo to read in one go. + private static final int MEMINFO_BUFFER_SIZE_BYTES = 256; + + // We don't mind an instant of possible duplicate work, we only wish to + // avoid inconsistency, so we don't bother with synchronization for + // these. + private static volatile int cpuCount = -1; + + private static volatile int totalRAM = -1; + + /** + * Get the number of cores on the device. + * + * We can't use a nice tidy API call, because they're all wrong: + * + * <http://stackoverflow.com/questions/7962155/how-can-you-detect-a-dual-core- + * cpu-on-an-android-device-from-code> + * + * This method is based on that code. + * + * @return the number of CPU cores, or 1 if the number could not be + * determined. + */ + public static int getCPUCount() { + if (cpuCount > 0) { + return cpuCount; + } + + // Avoid a strict mode warning. + StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + try { + return readCPUCount(); + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + private static int readCPUCount() { + class CpuFilter implements FileFilter { + @Override + public boolean accept(File pathname) { + return Pattern.matches("cpu[0-9]+", pathname.getName()); + } + } + try { + final File dir = new File("/sys/devices/system/cpu/"); + return cpuCount = dir.listFiles(new CpuFilter()).length; + } catch (Exception e) { + Log.w(LOG_TAG, "Assuming 1 CPU; got exception.", e); + return cpuCount = 1; + } + } + + /** + * Helper functions used to extract key/value data from /proc/meminfo + * Pulled from: + * http://androidxref.com/4.2_r1/xref/frameworks/base/core/java/com/android/internal/util/MemInfoReader.java + */ + private static boolean matchMemText(byte[] buffer, int index, int bufferLength, byte[] text) { + final int N = text.length; + if ((index + N) >= bufferLength) { + return false; + } + for (int i = 0; i < N; i++) { + if (buffer[index + i] != text[i]) { + return false; + } + } + return true; + } + + /** + * Parses a line like: + * + * MemTotal: 1605324 kB + * + * into 1605324. + * + * @return the first uninterrupted sequence of digits following the + * specified index, parsed as an integer value in KB. + */ + private static int extractMemValue(byte[] buffer, int offset, int length) { + if (offset >= length) { + return 0; + } + + while (offset < length && buffer[offset] != '\n') { + if (buffer[offset] >= '0' && buffer[offset] <= '9') { + int start = offset++; + while (offset < length && + buffer[offset] >= '0' && + buffer[offset] <= '9') { + ++offset; + } + return Integer.parseInt(new String(buffer, start, offset - start), 10); + } + ++offset; + } + return 0; + } + + /** + * Fetch the total memory of the device in MB by parsing /proc/meminfo. + * + * Of course, Android doesn't have a neat and tidy way to find total + * RAM, so we do it by parsing /proc/meminfo. + * + * @return 0 if a problem occurred, or memory size in MB. + */ + public static int getMemSize() { + if (totalRAM >= 0) { + return totalRAM; + } + + // This is the string "MemTotal" that we're searching for in the buffer. + final byte[] MEMTOTAL = {'M', 'e', 'm', 'T', 'o', 't', 'a', 'l'}; + + // `/proc/meminfo` is not a real file and thus safe to read on the main thread. + final StrictMode.ThreadPolicy savedPolicy = StrictMode.allowThreadDiskReads(); + try { + final byte[] buffer = new byte[MEMINFO_BUFFER_SIZE_BYTES]; + final FileInputStream is = new FileInputStream("/proc/meminfo"); + try { + final int length = is.read(buffer); + + for (int i = 0; i < length; i++) { + if (matchMemText(buffer, i, length, MEMTOTAL)) { + i += 8; + totalRAM = extractMemValue(buffer, i, length) / 1024; + Log.d(LOG_TAG, "System memory: " + totalRAM + "MB."); + return totalRAM; + } + } + } finally { + is.close(); + } + + Log.w(LOG_TAG, "Did not find MemTotal line in /proc/meminfo."); + return totalRAM = 0; + } catch (FileNotFoundException f) { + return totalRAM = 0; + } catch (IOException e) { + return totalRAM = 0; + } finally { + StrictMode.setThreadPolicy(savedPolicy); + } + } + + /** + * @return the SDK version supported by this device, such as '16'. + */ + public static int getVersion() { + return android.os.Build.VERSION.SDK_INT; + } + + /** + * @return the release version string, such as "4.1.2". + */ + public static String getReleaseVersion() { + return android.os.Build.VERSION.RELEASE; + } + + /** + * @return the kernel version string, such as "3.4.10-geb45596". + */ + public static String getKernelVersion() { + return System.getProperty("os.version", ""); + } + + /** + * @return the device manufacturer, such as "HTC". + */ + public static String getManufacturer() { + return android.os.Build.MANUFACTURER; + } + + /** + * @return the device name, such as "HTC One". + */ + public static String getDevice() { + // No, not android.os.Build.DEVICE. + return android.os.Build.MODEL; + } + + /** + * @return the Android "hardware" identifier, such as "m7". + */ + public static String getHardware() { + return android.os.Build.HARDWARE; + } + + /** + * @return the system OS name. Hardcoded to "Android". + */ + public static String getName() { + // We deliberately differ from PR_SI_SYSNAME, which is "Linux". + return "Android"; + } + + /** + * @return the Android architecture string, including ABI. + */ + public static String getArchABI() { + // Android likes to include the ABI, too ("armeabiv7"), so we + // differ to add value. + return android.os.Build.CPU_ABI; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java new file mode 100644 index 000000000..41a71dfa5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TouchEventInterceptor.java @@ -0,0 +1,14 @@ +/* -*- 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.view.MotionEvent; +import android.view.View; + +public interface TouchEventInterceptor extends View.OnTouchListener { + /** Override this method for a chance to consume events before the view or its children */ + public boolean onInterceptTouchEvent(View view, MotionEvent event); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java new file mode 100644 index 000000000..d6140a1ff --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/JNITarget.java @@ -0,0 +1,14 @@ +/* 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface JNITarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java new file mode 100644 index 000000000..e873ebeb9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/ReflectionTarget.java @@ -0,0 +1,18 @@ +/* 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/* + * Used to indicate to ProGuard that this definition is accessed + * via reflection and should not be stripped from the source. + */ +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface ReflectionTarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java new file mode 100644 index 000000000..e15130674 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.java @@ -0,0 +1,15 @@ +/* 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface RobocopTarget {} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java new file mode 100644 index 000000000..f58dea148 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WebRTCJNITarget.java @@ -0,0 +1,14 @@ +/* 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface WebRTCJNITarget {} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java new file mode 100644 index 000000000..358ed5d56 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java @@ -0,0 +1,51 @@ +/* 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.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * This annotation is used to tag methods that are to have wrapper methods generated. + * Such methods will be protected from destruction by ProGuard, and allow us to avoid + * writing by hand large amounts of boring boilerplate. + */ +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR}) +@Retention(RetentionPolicy.RUNTIME) +public @interface WrapForJNI { + /** + * Skip this member when generating wrappers for a whole class. + */ + boolean skip() default false; + + /** + * Optional parameter specifying the name of the generated method stub. If omitted, + * the capitalized name of the Java method will be used. + */ + String stubName() default ""; + + /** + * Action to take if member access returns an exception. + * One of "abort", "ignore", or "nsresult". "nsresult" is not supported for native + * methods. + */ + String exceptionMode() default "abort"; + + /** + * The thread that the method will be called from. + * One of "any", "gecko", or "ui". Not supported for fields. + */ + String calledFrom() default "any"; + + /** + * The thread that the method call will be dispatched to. + * One of "current", "gecko", or "proxy". Not supported for non-native methods, + * fields, and constructors. Only void-return methods are supported for anything other + * than current thread. + */ + String dispatchTo() default "current"; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BitmapUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BitmapUtils.java new file mode 100644 index 000000000..a4b516519 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BitmapUtils.java @@ -0,0 +1,290 @@ +/* -*- 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.gfx; + +import java.io.IOException; +import java.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; + +public final class BitmapUtils { + private static final String LOGTAG = "GeckoBitmapUtils"; + + private BitmapUtils() {} + + public static Bitmap decodeByteArray(byte[] bytes) { + return decodeByteArray(bytes, null); + } + + public static Bitmap decodeByteArray(byte[] bytes, BitmapFactory.Options options) { + return decodeByteArray(bytes, 0, bytes.length, options); + } + + public static Bitmap decodeByteArray(byte[] bytes, int offset, int length) { + return decodeByteArray(bytes, offset, length, null); + } + + public static Bitmap decodeByteArray(byte[] bytes, int offset, int length, BitmapFactory.Options options) { + if (bytes.length <= 0) { + throw new IllegalArgumentException("bytes.length " + bytes.length + + " must be a positive number"); + } + + Bitmap bitmap = null; + try { + bitmap = BitmapFactory.decodeByteArray(bytes, offset, length, options); + } catch (OutOfMemoryError e) { + Log.e(LOGTAG, "decodeByteArray(bytes.length=" + bytes.length + + ", options= " + options + ") OOM!", e); + return null; + } + + if (bitmap == null) { + Log.w(LOGTAG, "decodeByteArray() returning null because BitmapFactory returned null"); + return null; + } + + if (bitmap.getWidth() <= 0 || bitmap.getHeight() <= 0) { + Log.w(LOGTAG, "decodeByteArray() returning null because BitmapFactory returned " + + "a bitmap with dimensions " + bitmap.getWidth() + + "x" + bitmap.getHeight()); + return null; + } + + return bitmap; + } + + public static Bitmap decodeStream(InputStream inputStream) { + try { + return BitmapFactory.decodeStream(inputStream); + } catch (OutOfMemoryError e) { + Log.e(LOGTAG, "decodeStream() OOM!", e); + return null; + } + } + + public static Bitmap decodeUrl(Uri uri) { + return decodeUrl(uri.toString()); + } + + public static Bitmap decodeUrl(String urlString) { + URL url; + + try { + url = new URL(urlString); + } catch (MalformedURLException e) { + Log.w(LOGTAG, "decodeUrl: malformed URL " + urlString); + return null; + } + + return decodeUrl(url); + } + + public static Bitmap decodeUrl(URL url) { + InputStream stream = null; + + try { + stream = url.openStream(); + } catch (IOException e) { + Log.w(LOGTAG, "decodeUrl: IOException downloading " + url); + return null; + } + + if (stream == null) { + Log.w(LOGTAG, "decodeUrl: stream not found downloading " + url); + return null; + } + + Bitmap bitmap = decodeStream(stream); + + try { + stream.close(); + } catch (IOException e) { + Log.w(LOGTAG, "decodeUrl: IOException closing stream " + url, e); + } + + return bitmap; + } + + public static Bitmap decodeResource(Context context, int id) { + return decodeResource(context, id, null); + } + + public static Bitmap decodeResource(Context context, int id, BitmapFactory.Options options) { + Resources resources = context.getResources(); + try { + return BitmapFactory.decodeResource(resources, id, options); + } catch (OutOfMemoryError e) { + Log.e(LOGTAG, "decodeResource() OOM! Resource id=" + id, e); + return null; + } + } + + public static int getDominantColor(Bitmap source) { + return getDominantColor(source, true); + } + + public static int getDominantColor(Bitmap source, boolean applyThreshold) { + if (source == null) + return Color.argb(255, 255, 255, 255); + + // Keep track of how many times a hue in a given bin appears in the image. + // Hue values range [0 .. 360), so dividing by 10, we get 36 bins. + int[] colorBins = new int[36]; + + // The bin with the most colors. Initialize to -1 to prevent accidentally + // thinking the first bin holds the dominant color. + int maxBin = -1; + + // Keep track of sum hue/saturation/value per hue bin, which we'll use to + // compute an average to for the dominant color. + float[] sumHue = new float[36]; + float[] sumSat = new float[36]; + float[] sumVal = new float[36]; + float[] hsv = new float[3]; + + int height = source.getHeight(); + int width = source.getWidth(); + int[] pixels = new int[width * height]; + source.getPixels(pixels, 0, width, 0, 0, width, height); + for (int row = 0; row < height; row++) { + for (int col = 0; col < width; col++) { + int c = pixels[col + row * width]; + // Ignore pixels with a certain transparency. + if (Color.alpha(c) < 128) + continue; + + Color.colorToHSV(c, hsv); + + // If a threshold is applied, ignore arbitrarily chosen values for "white" and "black". + if (applyThreshold && (hsv[1] <= 0.35f || hsv[2] <= 0.35f)) + continue; + + // We compute the dominant color by putting colors in bins based on their hue. + int bin = (int) Math.floor(hsv[0] / 10.0f); + + // Update the sum hue/saturation/value for this bin. + sumHue[bin] = sumHue[bin] + hsv[0]; + sumSat[bin] = sumSat[bin] + hsv[1]; + sumVal[bin] = sumVal[bin] + hsv[2]; + + // Increment the number of colors in this bin. + colorBins[bin]++; + + // Keep track of the bin that holds the most colors. + if (maxBin < 0 || colorBins[bin] > colorBins[maxBin]) + maxBin = bin; + } + } + + // maxBin may never get updated if the image holds only transparent and/or black/white pixels. + if (maxBin < 0) + return Color.argb(255, 255, 255, 255); + + // Return a color with the average hue/saturation/value of the bin with the most colors. + hsv[0] = sumHue[maxBin] / colorBins[maxBin]; + hsv[1] = sumSat[maxBin] / colorBins[maxBin]; + hsv[2] = sumVal[maxBin] / colorBins[maxBin]; + return Color.HSVToColor(hsv); + } + + /** + * Decodes a bitmap from a Base64 data URI. + * + * @param dataURI a Base64-encoded data URI string + * @return the decoded bitmap, or null if the data URI is invalid + */ + public static Bitmap getBitmapFromDataURI(String dataURI) { + if (dataURI == null) { + return null; + } + + byte[] raw = getBytesFromDataURI(dataURI); + if (raw == null || raw.length == 0) { + return null; + } + + return decodeByteArray(raw); + } + + /** + * Return a byte[] containing the bytes in a given base64 string, or null if this is not a valid + * base64 string. + */ + public static byte[] getBytesFromBase64(String base64) { + try { + return Base64.decode(base64, Base64.DEFAULT); + } catch (Exception e) { + Log.e(LOGTAG, "exception decoding bitmap from data URI: " + base64, e); + } + + return null; + } + + public static byte[] getBytesFromDataURI(String dataURI) { + final String base64 = dataURI.substring(dataURI.indexOf(',') + 1); + return getBytesFromBase64(base64); + } + + public static Bitmap getBitmapFromDrawable(Drawable drawable) { + if (drawable instanceof BitmapDrawable) { + return ((BitmapDrawable) drawable).getBitmap(); + } + + int width = drawable.getIntrinsicWidth(); + width = width > 0 ? width : 1; + int height = drawable.getIntrinsicHeight(); + height = height > 0 ? height : 1; + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + public static int getResource(final Context context, final Uri resourceUrl) { + final String scheme = resourceUrl.getScheme(); + if (!"drawable".equals(scheme)) { + // Return a "not found" default icon that's easy to spot. + return android.R.drawable.sym_def_app_icon; + } + + String resource = resourceUrl.getSchemeSpecificPart(); + if (resource.startsWith("//")) { + resource = resource.substring(2); + } + + final Resources res = context.getResources(); + int id = res.getIdentifier(resource, "drawable", context.getPackageName()); + if (id != 0) { + return id; + } + + // For backwards compatibility, we also search in system resources. + id = res.getIdentifier(resource, "drawable", "android"); + if (id != 0) { + return id; + } + + Log.w(LOGTAG, "Cannot find drawable/" + resource); + // Return a "not found" default icon that's easy to spot. + return android.R.drawable.sym_def_app_icon; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImage.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImage.java new file mode 100644 index 000000000..4dbcf61bb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImage.java @@ -0,0 +1,94 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.mozglue.DirectBufferAllocator; + +import android.graphics.Bitmap; +import android.util.Log; + +import java.nio.ByteBuffer; + +/** A buffered image that simply saves a buffer of pixel data. */ +public class BufferedImage { + private ByteBuffer mBuffer; + private Bitmap mBitmap; + private IntSize mSize; + private int mFormat; + + private static final String LOGTAG = "GeckoBufferedImage"; + + /** Creates an empty buffered image */ + public BufferedImage() { + mSize = new IntSize(0, 0); + } + + /** Creates a buffered image from an Android bitmap. */ + public BufferedImage(Bitmap bitmap) { + mFormat = bitmapConfigToFormat(bitmap.getConfig()); + mSize = new IntSize(bitmap.getWidth(), bitmap.getHeight()); + mBitmap = bitmap; + } + + private synchronized void freeBuffer() { + if (mBuffer != null) { + mBuffer = DirectBufferAllocator.free(mBuffer); + } + } + + public void destroy() { + try { + freeBuffer(); + } catch (Exception ex) { + Log.e(LOGTAG, "error clearing buffer: ", ex); + } + } + + public ByteBuffer getBuffer() { + if (mBuffer == null) { + int bpp = bitsPerPixelForFormat(mFormat); + mBuffer = DirectBufferAllocator.allocate(mSize.getArea() * bpp); + mBitmap.copyPixelsToBuffer(mBuffer.asIntBuffer()); + mBitmap = null; + } + return mBuffer; + } + + public IntSize getSize() { return mSize; } + public int getFormat() { return mFormat; } + + public static final int FORMAT_INVALID = -1; + public static final int FORMAT_ARGB32 = 0; + public static final int FORMAT_RGB24 = 1; + public static final int FORMAT_A8 = 2; + public static final int FORMAT_A1 = 3; + public static final int FORMAT_RGB16_565 = 4; + + private static int bitsPerPixelForFormat(int format) { + switch (format) { + case FORMAT_A1: return 1; + case FORMAT_A8: return 8; + case FORMAT_RGB16_565: return 16; + case FORMAT_RGB24: return 24; + case FORMAT_ARGB32: return 32; + default: + throw new RuntimeException("Unknown Cairo format"); + } + } + + private static int bitmapConfigToFormat(Bitmap.Config config) { + if (config == null) + return FORMAT_ARGB32; /* Droid Pro fix. */ + + switch (config) { + case ALPHA_8: return FORMAT_A8; + case ARGB_4444: throw new RuntimeException("ARGB_444 unsupported"); + case ARGB_8888: return FORMAT_ARGB32; + case RGB_565: return FORMAT_RGB16_565; + default: throw new RuntimeException("Unknown Skia bitmap config"); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImageGLInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImageGLInfo.java new file mode 100644 index 000000000..41f38e1ba --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/BufferedImageGLInfo.java @@ -0,0 +1,35 @@ +/* -*- 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.gfx; + +import javax.microedition.khronos.opengles.GL10; + +/** Information needed to render buffered bitmaps using OpenGL ES. */ +public class BufferedImageGLInfo { + public final int internalFormat; + public final int format; + public final int type; + + public BufferedImageGLInfo(int bufferedImageFormat) { + switch (bufferedImageFormat) { + case BufferedImage.FORMAT_ARGB32: + internalFormat = format = GL10.GL_RGBA; type = GL10.GL_UNSIGNED_BYTE; + break; + case BufferedImage.FORMAT_RGB24: + internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_BYTE; + break; + case BufferedImage.FORMAT_RGB16_565: + internalFormat = format = GL10.GL_RGB; type = GL10.GL_UNSIGNED_SHORT_5_6_5; + break; + case BufferedImage.FORMAT_A8: + case BufferedImage.FORMAT_A1: + throw new RuntimeException("BufferedImage FORMAT_A1 and FORMAT_A8 unsupported"); + default: + throw new RuntimeException("Unknown BufferedImage format"); + } + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java new file mode 100644 index 000000000..e299b5744 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/DynamicToolbarAnimator.java @@ -0,0 +1,605 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.util.FloatUtils; +import org.mozilla.gecko.util.ThreadUtils; + +import android.graphics.PointF; +import android.support.v4.view.ViewCompat; +import android.util.Log; +import android.view.MotionEvent; +import android.view.animation.LinearInterpolator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +public class DynamicToolbarAnimator { + private static final String LOGTAG = "GeckoDynamicToolbarAnimator"; + private static final String PREF_SCROLL_TOOLBAR_THRESHOLD = "browser.ui.scroll-toolbar-threshold"; + + public static enum PinReason { + RELAYOUT, + ACTION_MODE, + FULL_SCREEN, + CARET_DRAG + } + + private final Set<PinReason> pinFlags = Collections.synchronizedSet(EnumSet.noneOf(PinReason.class)); + + // The duration of the animation in ns + private static final long ANIMATION_DURATION = 150000000; + + private final GeckoLayerClient mTarget; + private final List<LayerView.DynamicToolbarListener> mListeners; + + /* The translation to be applied to the toolbar UI view. This is the + * distance from the default/initial location (at the top of the screen, + * visible to the user) to where we want it to be. This variable should + * always be between 0 (toolbar fully visible) and the height of the toolbar + * (toolbar fully hidden), inclusive. + */ + private float mToolbarTranslation; + + /* The translation to be applied to the LayerView. This is the distance from + * the default/initial location (just below the toolbar, with the bottom + * extending past the bottom of the screen) to where we want it to be. + * This variable should always be between 0 and the height of the toolbar, + * inclusive. + */ + private float mLayerViewTranslation; + + /* This stores the maximum translation that can be applied to the toolbar + * and layerview when scrolling. This is populated with the height of the + * toolbar. */ + private float mMaxTranslation; + + /* This interpolator is used for the above mentioned animation */ + private LinearInterpolator mInterpolator; + + /* This is the proportion of the viewport rect that needs to be travelled + * while scrolling before the translation will start taking effect. + */ + private float SCROLL_TOOLBAR_THRESHOLD = 0.20f; + /* The ID of the prefs listener for the scroll-toolbar threshold */ + private final PrefsHelper.PrefHandler mPrefObserver; + + /* While we are resizing the viewport to account for the toolbar, the Java + * code and painted layer metrics in the compositor have different notions + * of the CSS viewport height. The Java value is stored in the + * GeckoLayerClient's viewport metrics, and the Gecko one is stored here. + * This allows us to adjust fixed-pos items correctly. + * You must synchronize on mTarget.getLock() to read/write this. */ + private Integer mHeightDuringResize; + + /* This tracks if we should trigger a "snap" on the next composite. A "snap" + * is when we simultaneously move the LayerView and change the scroll offset + * in the compositor so that everything looks the same on the screen but + * has really been shifted. + * You must synchronize on |this| to read/write this. */ + private boolean mSnapRequired = false; + + /* The task that handles showing/hiding toolbar */ + private DynamicToolbarAnimationTask mAnimationTask; + + /* The start point of a drag, used for scroll-based dynamic toolbar + * behaviour. */ + private PointF mTouchStart; + private float mLastTouch; + + /* Set to true when root content is being scrolled */ + private boolean mScrollingRootContent; + + public DynamicToolbarAnimator(GeckoLayerClient aTarget) { + mTarget = aTarget; + mListeners = new ArrayList<LayerView.DynamicToolbarListener>(); + + mInterpolator = new LinearInterpolator(); + + // Listen to the dynamic toolbar pref + mPrefObserver = new PrefsHelper.PrefHandlerBase() { + @Override + public void prefValue(String pref, int value) { + SCROLL_TOOLBAR_THRESHOLD = value / 100.0f; + } + }; + PrefsHelper.addObserver(new String[] { PREF_SCROLL_TOOLBAR_THRESHOLD }, mPrefObserver); + } + + public void destroy() { + PrefsHelper.removeObserver(mPrefObserver); + } + + public void addTranslationListener(LayerView.DynamicToolbarListener aListener) { + mListeners.add(aListener); + } + + public void removeTranslationListener(LayerView.DynamicToolbarListener aListener) { + mListeners.remove(aListener); + } + + private void fireListeners() { + for (LayerView.DynamicToolbarListener listener : mListeners) { + listener.onTranslationChanged(mToolbarTranslation, mLayerViewTranslation); + } + } + + void onPanZoomStopped() { + for (LayerView.DynamicToolbarListener listener : mListeners) { + listener.onPanZoomStopped(); + } + } + + void onMetricsChanged(ImmutableViewportMetrics aMetrics) { + for (LayerView.DynamicToolbarListener listener : mListeners) { + listener.onMetricsChanged(aMetrics); + } + } + + public void setMaxTranslation(float maxTranslation) { + ThreadUtils.assertOnUiThread(); + if (maxTranslation < 0) { + Log.e(LOGTAG, "Got a negative max-translation value: " + maxTranslation + "; clamping to zero"); + mMaxTranslation = 0; + } else { + mMaxTranslation = maxTranslation; + } + } + + public float getMaxTranslation() { + return mMaxTranslation; + } + + public float getToolbarTranslation() { + return mToolbarTranslation; + } + + /** + * If true, scroll changes will not affect translation. + */ + public boolean isPinned() { + return !pinFlags.isEmpty(); + } + + public boolean isPinnedBy(PinReason reason) { + return pinFlags.contains(reason); + } + + public void setPinned(boolean pinned, PinReason reason) { + if (pinned) { + pinFlags.add(reason); + } else { + pinFlags.remove(reason); + } + } + + public void showToolbar(boolean immediately) { + animateToolbar(true, immediately); + } + + public void hideToolbar(boolean immediately) { + animateToolbar(false, immediately); + } + + public void setScrollingRootContent(boolean isRootContent) { + mScrollingRootContent = isRootContent; + } + + private void animateToolbar(final boolean showToolbar, boolean immediately) { + ThreadUtils.assertOnUiThread(); + + if (mAnimationTask != null) { + mTarget.getView().removeRenderTask(mAnimationTask); + mAnimationTask = null; + } + + float desiredTranslation = (showToolbar ? 0 : mMaxTranslation); + Log.v(LOGTAG, "Requested " + (immediately ? "immediate " : "") + "toolbar animation to translation " + desiredTranslation); + if (FloatUtils.fuzzyEquals(mToolbarTranslation, desiredTranslation)) { + // If we're already pretty much in the desired position, don't bother + // with a full animation; do an immediate jump + immediately = true; + Log.v(LOGTAG, "Changing animation to immediate jump"); + } + + if (showToolbar && immediately) { + // Special case for showing the toolbar immediately: some of the call + // sites expect this to happen synchronously, so let's do that. This + // is safe because if we are showing the toolbar from a hidden state + // there is no chance of showing garbage + mToolbarTranslation = desiredTranslation; + fireListeners(); + // And then proceed with the normal flow (some of which will be + // a no-op now)... + } + + if (!showToolbar) { + // If we are hiding the toolbar, we need to move the LayerView first, + // so that we don't end up showing garbage under the toolbar when + // it is hidden. In the case that we are showing the toolbar, we + // move the LayerView after the toolbar is shown - the + // DynamicToolbarAnimationTask calls that upon completion. + shiftLayerView(desiredTranslation); + } + + mAnimationTask = new DynamicToolbarAnimationTask(desiredTranslation, immediately, showToolbar); + mTarget.getView().postRenderTask(mAnimationTask); + } + + private synchronized void shiftLayerView(float desiredTranslation) { + float layerViewTranslationNeeded = desiredTranslation - mLayerViewTranslation; + mLayerViewTranslation = desiredTranslation; + synchronized (mTarget.getLock()) { + if (layerViewTranslationNeeded == 0 && isResizing()) { + // We're already in the middle of a snap, so this new call is + // redundant as it's snapping to the same place. Ignore it. + return; + } + mHeightDuringResize = new Integer(mTarget.getViewportMetrics().viewportRectHeight); + mSnapRequired = mTarget.setViewportSize( + mTarget.getView().getWidth(), + mTarget.getView().getHeight() - Math.round(mMaxTranslation - mLayerViewTranslation), + new PointF(0, -layerViewTranslationNeeded)); + if (!mSnapRequired) { + mHeightDuringResize = null; + ThreadUtils.postToUiThread(new Runnable() { + // Post to run it outside of the synchronize blocks. The + // delay shouldn't hurt. + @Override + public void run() { + fireListeners(); + } + }); + } + // Request a composite, which will trigger the snap. + mTarget.getView().requestRender(); + } + } + + IntSize getViewportSize() { + ThreadUtils.assertOnUiThread(); + + int viewWidth = mTarget.getView().getWidth(); + int viewHeight = mTarget.getView().getHeight(); + float toolbarTranslation = mToolbarTranslation; + if (mAnimationTask != null) { + // If we have an animation going, mToolbarTranslation may be in flux + // and we should use the final value it will settle on. + toolbarTranslation = mAnimationTask.getFinalToolbarTranslation(); + } + int viewHeightVisible = viewHeight - Math.round(mMaxTranslation - toolbarTranslation); + return new IntSize(viewWidth, viewHeightVisible); + } + + boolean isResizing() { + return mHeightDuringResize != null; + } + + private final Runnable mSnapRunnable = new Runnable() { + private int mFrame = 0; + + @Override + public final void run() { + // It takes 2 frames for the view translation to take effect, at + // least on a Nexus 4 device running Android 4.2.2. So we wait for + // two frames before doing the notifyAll(), otherwise we get a + // short user-visible glitch. + // TODO: find a better way to do this, if possible. + if (mFrame == 1) { + synchronized (this) { + this.notifyAll(); + } + mFrame = 0; + return; + } + + if (mFrame == 0) { + fireListeners(); + } + + ViewCompat.postOnAnimation(mTarget.getView(), this); + mFrame++; + } + }; + + void scrollChangeResizeCompleted() { + synchronized (mTarget.getLock()) { + Log.v(LOGTAG, "Scrollchange resize completed"); + mHeightDuringResize = null; + } + } + + /** + * "Shrinks" the absolute value of aValue by moving it closer to zero by + * aShrinkAmount, but prevents it from crossing over zero. If aShrinkAmount + * is negative it is ignored. + * @return The shrunken value. + */ + private static float shrinkAbs(float aValue, float aShrinkAmount) { + if (aShrinkAmount <= 0) { + return aValue; + } + float shrinkBy = Math.min(Math.abs(aValue), aShrinkAmount); + return (aValue < 0 ? aValue + shrinkBy : aValue - shrinkBy); + } + + /** + * This function takes in a scroll amount and decides how much of that + * should be used up to translate things on screen because of the dynamic + * toolbar behaviour. It returns the maximum amount that could be used + * for translation purposes; the rest must be used for scrolling. + */ + private float decideTranslation(float aDelta, + ImmutableViewportMetrics aMetrics, + float aTouchTravelDistance) { + + float exposeThreshold = aMetrics.getHeight() * SCROLL_TOOLBAR_THRESHOLD; + float translation = aDelta; + + if (translation < 0) { // finger moving upwards + translation = shrinkAbs(translation, aMetrics.getOverscroll().top); + + // If the toolbar is in a state between fully hidden and fully shown + // (i.e. the user is actively translating it), then we want the + // translation to take effect right away. Or if the user has moved + // their finger past the required threshold (and is not trying to + // scroll past the bottom of the page) then also we want the touch + // to cause translation. If the toolbar is fully visible, we only + // want the toolbar to hide if the user is scrolling the root content. + boolean inBetween = (mToolbarTranslation != 0 && mToolbarTranslation != mMaxTranslation); + boolean reachedThreshold = -aTouchTravelDistance >= exposeThreshold; + boolean atBottomOfPage = aMetrics.viewportRectBottom() >= aMetrics.pageRectBottom; + if (inBetween || (mScrollingRootContent && reachedThreshold && !atBottomOfPage)) { + return translation; + } + } else { // finger moving downwards + translation = shrinkAbs(translation, aMetrics.getOverscroll().bottom); + + // Ditto above comment, but in this case if they reached the top and + // the toolbar is not shown, then we do want to allow translation + // right away. + boolean inBetween = (mToolbarTranslation != 0 && mToolbarTranslation != mMaxTranslation); + boolean reachedThreshold = aTouchTravelDistance >= exposeThreshold; + boolean atTopOfPage = aMetrics.viewportRectTop <= aMetrics.pageRectTop; + boolean isToolbarTranslated = (mToolbarTranslation != 0); + if (inBetween || reachedThreshold || (atTopOfPage && isToolbarTranslated)) { + return translation; + } + } + + return 0; + } + + // Timestamp of the start of the touch event used to calculate toolbar velocity + private long mLastEventTime; + // Current velocity of the toolbar. Used to populate the velocity queue in C++APZ. + private float mVelocity; + + boolean onInterceptTouchEvent(MotionEvent event) { + if (isPinned()) { + return false; + } + + // Animations should never co-exist with the user touching the screen. + if (mAnimationTask != null) { + mTarget.getView().removeRenderTask(mAnimationTask); + mAnimationTask = null; + } + + // we only care about single-finger drags here; any other kind of event + // should reset and cause us to start over. + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE || + event.getActionMasked() != MotionEvent.ACTION_MOVE || + event.getPointerCount() != 1) + { + if (mTouchStart != null) { + Log.v(LOGTAG, "Resetting touch sequence due to non-move"); + mTouchStart = null; + mVelocity = 0.0f; + } + + if (event.getActionMasked() == MotionEvent.ACTION_UP) { + // We need to do this even if the toolbar is already fully + // visible or fully hidden, because this is what triggers the + // viewport resize in content and updates the viewport metrics. + boolean toolbarMostlyVisible = mToolbarTranslation < (mMaxTranslation / 2); + Log.v(LOGTAG, "All fingers lifted, completing " + (toolbarMostlyVisible ? "show" : "hide")); + animateToolbar(toolbarMostlyVisible, false); + } + return false; + } + + if (mTouchStart != null) { + float prevDir = mLastTouch - mTouchStart.y; + float newDir = event.getRawY() - mLastTouch; + if (prevDir != 0 && newDir != 0 && ((prevDir < 0) != (newDir < 0))) { + // If the direction of movement changed, reset the travel + // distance properties. + mTouchStart = null; + mVelocity = 0.0f; + } + } + + if (mTouchStart == null) { + mTouchStart = new PointF(event.getRawX(), event.getRawY()); + mLastTouch = event.getRawY(); + mLastEventTime = event.getEventTime(); + return false; + } + + float deltaY = event.getRawY() - mLastTouch; + long currentTime = event.getEventTime(); + float deltaTime = (float)(currentTime - mLastEventTime); + mLastEventTime = currentTime; + if (deltaTime > 0.0f) { + mVelocity = -deltaY / deltaTime; + } else { + mVelocity = 0.0f; + } + mLastTouch = event.getRawY(); + float travelDistance = event.getRawY() - mTouchStart.y; + + ImmutableViewportMetrics metrics = mTarget.getViewportMetrics(); + + if (metrics.getPageHeight() <= mTarget.getView().getHeight() && + mToolbarTranslation == 0) { + // If the page is short and the toolbar is already visible, don't + // allow translating it out of view. + return false; + } + + float translation = decideTranslation(deltaY, metrics, travelDistance); + + float oldToolbarTranslation = mToolbarTranslation; + float oldLayerViewTranslation = mLayerViewTranslation; + mToolbarTranslation = FloatUtils.clamp(mToolbarTranslation - translation, 0, mMaxTranslation); + mLayerViewTranslation = FloatUtils.clamp(mLayerViewTranslation - translation, 0, mMaxTranslation); + + if (oldToolbarTranslation == mToolbarTranslation && + oldLayerViewTranslation == mLayerViewTranslation) { + return false; + } + + if (mToolbarTranslation == mMaxTranslation) { + Log.v(LOGTAG, "Toolbar at maximum translation, calling shiftLayerView(" + mMaxTranslation + ")"); + shiftLayerView(mMaxTranslation); + } else if (mToolbarTranslation == 0) { + Log.v(LOGTAG, "Toolbar at minimum translation, calling shiftLayerView(0)"); + shiftLayerView(0); + } + + fireListeners(); + mTarget.getView().requestRender(); + return true; + } + + // Get the current velocity of the toolbar. + float getVelocity() { + return mVelocity; + } + + public PointF getVisibleEndOfLayerView() { + return new PointF(mTarget.getView().getWidth(), + mTarget.getView().getHeight() - mMaxTranslation + mLayerViewTranslation); + } + + private float bottomOfCssViewport(ImmutableViewportMetrics aMetrics) { + return (isResizing() ? mHeightDuringResize : aMetrics.getHeight()) + + mMaxTranslation - mLayerViewTranslation; + } + + private synchronized boolean getAndClearSnapRequired() { + boolean snapRequired = mSnapRequired; + mSnapRequired = false; + return snapRequired; + } + + void populateViewTransform(ViewTransform aTransform, ImmutableViewportMetrics aMetrics) { + if (getAndClearSnapRequired()) { + synchronized (mSnapRunnable) { + ViewCompat.postOnAnimation(mTarget.getView(), mSnapRunnable); + try { + // hold the in-progress composite until the views have been + // translated because otherwise there is a visible glitch. + // don't hold for more than 100ms just in case. + mSnapRunnable.wait(100); + } catch (InterruptedException ie) { + } + } + } + + aTransform.x = aMetrics.viewportRectLeft; + aTransform.y = aMetrics.viewportRectTop; + aTransform.width = aMetrics.viewportRectWidth; + aTransform.height = aMetrics.viewportRectHeight; + aTransform.scale = aMetrics.zoomFactor; + + aTransform.fixedLayerMarginTop = mLayerViewTranslation - mToolbarTranslation; + float bottomOfScreen = mTarget.getView().getHeight(); + // We want to move a fixed item from "bottomOfCssViewport" to + // "bottomOfScreen". But also the bottom margin > 0 means that bottom + // fixed-pos items will move upwards. + aTransform.fixedLayerMarginBottom = bottomOfCssViewport(aMetrics) - bottomOfScreen; + //Log.v(LOGTAG, "ViewTransform is x=" + aTransform.x + " y=" + aTransform.y + // + " z=" + aTransform.scale + " t=" + aTransform.fixedLayerMarginTop + // + " b=" + aTransform.fixedLayerMarginBottom); + } + + class DynamicToolbarAnimationTask extends RenderTask { + private final float mStartTranslation; + private final float mEndTranslation; + private final boolean mImmediate; + private final boolean mShiftLayerView; + private boolean mContinueAnimation; + + public DynamicToolbarAnimationTask(float aTranslation, boolean aImmediate, boolean aShiftLayerView) { + super(false); + mContinueAnimation = true; + mStartTranslation = mToolbarTranslation; + mEndTranslation = aTranslation; + mImmediate = aImmediate; + mShiftLayerView = aShiftLayerView; + } + + float getFinalToolbarTranslation() { + return mEndTranslation; + } + + @Override + public boolean internalRun(long timeDelta, long currentFrameStartTime) { + if (!mContinueAnimation) { + return false; + } + + // Calculate the progress (between 0 and 1) + final float progress = mImmediate + ? 1.0f + : mInterpolator.getInterpolation( + Math.min(1.0f, (System.nanoTime() - getStartTime()) + / (float)ANIMATION_DURATION)); + + // This runs on the compositor thread, so we need to post the + // actual work to the UI thread. + ThreadUtils.assertNotOnUiThread(); + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Move the toolbar as per the animation + mToolbarTranslation = FloatUtils.interpolate(mStartTranslation, mEndTranslation, progress); + fireListeners(); + + if (mShiftLayerView && progress >= 1.0f) { + shiftLayerView(mEndTranslation); + } + } + }); + + mTarget.getView().requestRender(); + if (progress >= 1.0f) { + mContinueAnimation = false; + } + return mContinueAnimation; + } + } + + class SnapMetrics { + public final int viewportWidth; + public final int viewportHeight; + public final float scrollChangeY; + + SnapMetrics(ImmutableViewportMetrics aMetrics, float aScrollChange) { + viewportWidth = aMetrics.viewportRectWidth; + viewportHeight = aMetrics.viewportRectHeight; + scrollChangeY = aScrollChange; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FloatSize.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FloatSize.java new file mode 100644 index 000000000..4b495ab77 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FloatSize.java @@ -0,0 +1,54 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.util.FloatUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +public class FloatSize { + public final float width, height; + + public FloatSize(FloatSize size) { width = size.width; height = size.height; } + public FloatSize(IntSize size) { width = size.width; height = size.height; } + public FloatSize(float aWidth, float aHeight) { width = aWidth; height = aHeight; } + + public FloatSize(JSONObject json) { + try { + width = (float)json.getDouble("width"); + height = (float)json.getDouble("height"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + @Override + public String toString() { return "(" + width + "," + height + ")"; } + + public boolean isPositive() { + return (width > 0 && height > 0); + } + + public boolean fuzzyEquals(FloatSize size) { + return (FloatUtils.fuzzyEquals(size.width, width) && + FloatUtils.fuzzyEquals(size.height, height)); + } + + public FloatSize scale(float factor) { + return new FloatSize(width * factor, height * factor); + } + + /* + * Returns the size that represents a linear transition between this size and `to` at time `t`, + * which is on the scale [0, 1). + */ + public FloatSize interpolate(FloatSize to, float t) { + return new FloatSize(FloatUtils.interpolate(width, to.width, t), + FloatUtils.interpolate(height, to.height, t)); + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FullScreenState.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FullScreenState.java new file mode 100644 index 000000000..9574bbe0e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/FullScreenState.java @@ -0,0 +1,12 @@ +/* -*- 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.gfx; + +public enum FullScreenState { + NONE, + ROOT_ELEMENT, + NON_ROOT_ELEMENT +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java new file mode 100644 index 000000000..d504fe13e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoLayerClient.java @@ -0,0 +1,694 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.gfx.LayerView.DrawListener; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.FloatUtils; +import org.mozilla.gecko.AppConstants; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.SystemClock; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.InputDevice; +import android.view.MotionEvent; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; + +class GeckoLayerClient implements LayerView.Listener, PanZoomTarget +{ + private static final String LOGTAG = "GeckoLayerClient"; + private static int sPaintSyncId = 1; + + private LayerRenderer mLayerRenderer; + private boolean mLayerRendererInitialized; + + private final Context mContext; + private IntSize mScreenSize; + private IntSize mWindowSize; + + /* + * The viewport metrics being used to draw the current frame. This is only + * accessed by the compositor thread, and so needs no synchronisation. + */ + private ImmutableViewportMetrics mFrameMetrics; + + private final List<DrawListener> mDrawListeners; + + /* Used as temporaries by syncViewportInfo */ + private final ViewTransform mCurrentViewTransform; + + private boolean mForceRedraw; + + /* The current viewport metrics. + * This is volatile so that we can read and write to it from different threads. + * We avoid synchronization to make getting the viewport metrics from + * the compositor as cheap as possible. The viewport is immutable so + * we don't need to worry about anyone mutating it while we're reading from it. + * Specifically: + * 1) reading mViewportMetrics from any thread is fine without synchronization + * 2) writing to mViewportMetrics requires synchronizing on the layer controller object + * 3) whenever reading multiple fields from mViewportMetrics without synchronization (i.e. in + * case 1 above) you should always first grab a local copy of the reference, and then use + * that because mViewportMetrics might get reassigned in between reading the different + * fields. */ + private volatile ImmutableViewportMetrics mViewportMetrics; + + private volatile boolean mGeckoIsReady; + + /* package */ final PanZoomController mPanZoomController; + private final DynamicToolbarAnimator mToolbarAnimator; + /* package */ final LayerView mView; + + /* This flag is true from the time that browser.js detects a first-paint is about to start, + * to the time that we receive the first-paint composite notification from the compositor. + * Note that there is a small race condition with this; if there are two paints that both + * have the first-paint flag set, and the second paint happens concurrently with the + * composite for the first paint, then this flag may be set to true prematurely. Fixing this + * is possible but risky; see https://bugzilla.mozilla.org/show_bug.cgi?id=797615#c751 + */ + private volatile boolean mContentDocumentIsDisplayed; + + private SynthesizedEventState mPointerState; + + @WrapForJNI(stubName = "ClearColor") + private volatile int mClearColor = Color.WHITE; + + public GeckoLayerClient(Context context, LayerView view, EventDispatcher eventDispatcher) { + // we can fill these in with dummy values because they are always written + // to before being read + mContext = context; + mScreenSize = new IntSize(0, 0); + mWindowSize = new IntSize(0, 0); + mCurrentViewTransform = new ViewTransform(0, 0, 1); + + mForceRedraw = true; + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + mViewportMetrics = new ImmutableViewportMetrics(displayMetrics) + .setViewportSize(view.getWidth(), view.getHeight()); + + mFrameMetrics = mViewportMetrics; + + mDrawListeners = new ArrayList<DrawListener>(); + mToolbarAnimator = new DynamicToolbarAnimator(this); + mPanZoomController = PanZoomController.Factory.create(this, view, eventDispatcher); + mView = view; + mView.setListener(this); + mContentDocumentIsDisplayed = true; + } + + public void setOverscrollHandler(final Overscroll listener) { + mPanZoomController.setOverscrollHandler(listener); + } + + public void setGeckoReady(boolean ready) { + mGeckoIsReady = ready; + } + + @Override // PanZoomTarget + public boolean isGeckoReady() { + return mGeckoIsReady; + } + + /** Attaches to root layer so that Gecko appears. */ + @WrapForJNI(calledFrom = "gecko") + private void onGeckoReady() { + mGeckoIsReady = true; + + mLayerRenderer = mView.getRenderer(); + + sendResizeEventIfNecessary(true, null); + + // Gecko being ready is one of the two conditions (along with having an available + // surface) that cause us to create the compositor. So here, now that we know gecko + // is ready, call updateCompositor() to see if we can actually do the creation. + // This needs to run on the UI thread so that the surface validity can't change on + // us while we're in the middle of creating the compositor. + mView.post(new Runnable() { + @Override + public void run() { + mPanZoomController.attach(); + mView.updateCompositor(); + } + }); + } + + public void destroy() { + mPanZoomController.destroy(); + mToolbarAnimator.destroy(); + mDrawListeners.clear(); + mGeckoIsReady = false; + } + + public LayerView getView() { + return mView; + } + + public FloatSize getViewportSize() { + return mViewportMetrics.getSize(); + } + + /** + * The view calls this function to indicate that the viewport changed size. It must hold the + * monitor while calling it. + * + * TODO: Refactor this to use an interface. Expose that interface only to the view and not + * to the layer client. That way, the layer client won't be tempted to call this, which might + * result in an infinite loop. + */ + boolean setViewportSize(int width, int height, PointF scrollChange) { + if (mViewportMetrics.viewportRectWidth == width && + mViewportMetrics.viewportRectHeight == height && + (scrollChange == null || (scrollChange.x == 0 && scrollChange.y == 0))) { + return false; + } + mViewportMetrics = mViewportMetrics.setViewportSize(width, height); + if (scrollChange != null) { + mViewportMetrics = mPanZoomController.adjustScrollForSurfaceShift(mViewportMetrics, scrollChange); + } + + if (mGeckoIsReady) { + // here we send gecko a resize message. The code in browser.js is responsible for + // picking up on that resize event, modifying the viewport as necessary, and informing + // us of the new viewport. + sendResizeEventIfNecessary(true, scrollChange); + + // the following call also sends gecko a message, which will be processed after the resize + // message above has updated the viewport. this message ensures that if we have just put + // focus in a text field, we scroll the content so that the text field is in view. + GeckoAppShell.viewSizeChanged(); + } + return true; + } + + PanZoomController getPanZoomController() { + return mPanZoomController; + } + + DynamicToolbarAnimator getDynamicToolbarAnimator() { + return mToolbarAnimator; + } + + /* Informs Gecko that the screen size has changed. */ + private void sendResizeEventIfNecessary(boolean force, PointF scrollChange) { + DisplayMetrics metrics = mContext.getResources().getDisplayMetrics(); + + IntSize newScreenSize = new IntSize(metrics.widthPixels, metrics.heightPixels); + IntSize newWindowSize = new IntSize(mViewportMetrics.viewportRectWidth, + mViewportMetrics.viewportRectHeight); + + boolean screenSizeChanged = !mScreenSize.equals(newScreenSize); + boolean windowSizeChanged = !mWindowSize.equals(newWindowSize); + + if (!force && !screenSizeChanged && !windowSizeChanged) { + return; + } + + mScreenSize = newScreenSize; + mWindowSize = newWindowSize; + + if (screenSizeChanged) { + Log.d(LOGTAG, "Screen-size changed to " + mScreenSize); + } + + if (windowSizeChanged) { + Log.d(LOGTAG, "Window-size changed to " + mWindowSize); + } + + if (mView != null) { + mView.notifySizeChanged(mWindowSize.width, mWindowSize.height, + mScreenSize.width, mScreenSize.height); + } + + String json = ""; + try { + if (scrollChange != null) { + int id = ++sPaintSyncId; + if (id == 0) { + // never use 0 as that is the default value for "this is not + // a special transaction" + id = ++sPaintSyncId; + } + JSONObject jsonObj = new JSONObject(); + jsonObj.put("x", scrollChange.x / mViewportMetrics.zoomFactor); + jsonObj.put("y", scrollChange.y / mViewportMetrics.zoomFactor); + jsonObj.put("id", id); + json = jsonObj.toString(); + } + } catch (Exception e) { + Log.e(LOGTAG, "Unable to convert point to JSON", e); + } + GeckoAppShell.notifyObservers("Window:Resize", json); + } + + /** + * The different types of Viewport messages handled. All viewport events + * expect a display-port to be returned, but can handle one not being + * returned. + */ + private enum ViewportMessageType { + UPDATE, // The viewport has changed and should be entirely updated + PAGE_SIZE // The viewport's page-size has changed + } + + @WrapForJNI(calledFrom = "gecko") + void contentDocumentChanged() { + mContentDocumentIsDisplayed = false; + } + + @WrapForJNI(calledFrom = "gecko") + boolean isContentDocumentDisplayed() { + return mContentDocumentIsDisplayed; + } + + /** The compositor invokes this function just before compositing a frame where the document + * is different from the document composited on the last frame. In these cases, the viewport + * information we have in Java is no longer valid and needs to be replaced with the new + * viewport information provided. + */ + @WrapForJNI + public void setFirstPaintViewport(float offsetX, float offsetY, float zoom, + float cssPageLeft, float cssPageTop, float cssPageRight, float cssPageBottom) { + synchronized (getLock()) { + ImmutableViewportMetrics currentMetrics = getViewportMetrics(); + + RectF cssPageRect = new RectF(cssPageLeft, cssPageTop, cssPageRight, cssPageBottom); + RectF pageRect = RectUtils.scaleAndRound(cssPageRect, zoom); + + final ImmutableViewportMetrics newMetrics = currentMetrics + .setViewportOrigin(offsetX, offsetY) + .setZoomFactor(zoom) + .setPageRect(pageRect, cssPageRect); + // Since we have switched to displaying a different document, we need to update any + // viewport-related state we have lying around (i.e. mViewportMetrics). + // Usually this information is updated via handleViewportMessage + // while we remain on the same document. + setViewportMetrics(newMetrics, true); + + // Indicate that the document is about to be composited so the + // LayerView background can be removed. + if (mView.getPaintState() == LayerView.PAINT_START) { + mView.setPaintState(LayerView.PAINT_BEFORE_FIRST); + } + } + + mContentDocumentIsDisplayed = true; + } + + /** The compositor invokes this function on every frame to figure out what part of the + * page to display, and to inform Java of the current display port. Since it is called + * on every frame, it needs to be ultra-fast. + * It avoids taking any locks or allocating any objects. We keep around a + * mCurrentViewTransform so we don't need to allocate a new ViewTransform + * every time we're called. NOTE: we might be able to return a ImmutableViewportMetrics + * which would avoid the copy into mCurrentViewTransform. + */ + private ViewTransform syncViewportInfo(int x, int y, int width, int height, float resolution, boolean layersUpdated, + int paintSyncId) { + // getViewportMetrics is thread safe so we don't need to synchronize. + // We save the viewport metrics here, so we later use it later in + // createFrame (which will be called by nsWindow::DrawWindowUnderlay on + // the native side, by the compositor). The viewport + // metrics can change between here and there, as it's accessed outside + // of the compositor thread. + mFrameMetrics = getViewportMetrics(); + + if (paintSyncId == sPaintSyncId) { + mToolbarAnimator.scrollChangeResizeCompleted(); + } + mToolbarAnimator.populateViewTransform(mCurrentViewTransform, mFrameMetrics); + + if (layersUpdated) { + for (DrawListener listener : mDrawListeners) { + listener.drawFinished(); + } + } + + return mCurrentViewTransform; + } + + @WrapForJNI + public ViewTransform syncFrameMetrics(float scrollX, float scrollY, float zoom, + float cssPageLeft, float cssPageTop, float cssPageRight, float cssPageBottom, + int dpX, int dpY, int dpWidth, int dpHeight, float paintedResolution, + boolean layersUpdated, int paintSyncId) + { + // TODO: optimize this so it doesn't create so much garbage - it's a + // hot path + RectF cssPageRect = new RectF(cssPageLeft, cssPageTop, cssPageRight, cssPageBottom); + synchronized (getLock()) { + mViewportMetrics = mViewportMetrics.setViewportOrigin(scrollX, scrollY) + .setZoomFactor(zoom) + .setPageRect(RectUtils.scale(cssPageRect, zoom), cssPageRect); + } + return syncViewportInfo(dpX, dpY, dpWidth, dpHeight, paintedResolution, + layersUpdated, paintSyncId); + } + + class PointerInfo { + // We reserve one pointer ID for the mouse, so that tests don't have + // to worry about tracking pointer IDs if they just want to test mouse + // event synthesization. If somebody tries to use this ID for a + // synthesized touch event we'll throw an exception. + public static final int RESERVED_MOUSE_POINTER_ID = 100000; + + public int pointerId; + public int source; + public int screenX; + public int screenY; + public double pressure; + public int orientation; + + public MotionEvent.PointerCoords getCoords() { + MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.orientation = orientation; + coords.pressure = (float)pressure; + coords.x = screenX; + coords.y = screenY; + return coords; + } + } + + class SynthesizedEventState { + public final ArrayList<PointerInfo> pointers; + public long downTime; + + SynthesizedEventState() { + pointers = new ArrayList<PointerInfo>(); + } + + int getPointerIndex(int pointerId) { + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).pointerId == pointerId) { + return i; + } + } + return -1; + } + + int addPointer(int pointerId, int source) { + PointerInfo info = new PointerInfo(); + info.pointerId = pointerId; + info.source = source; + pointers.add(info); + return pointers.size() - 1; + } + + int getPointerCount(int source) { + int count = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + count++; + } + } + return count; + } + + MotionEvent.PointerProperties[] getPointerProperties(int source) { + MotionEvent.PointerProperties[] props = new MotionEvent.PointerProperties[getPointerCount(source)]; + int index = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + MotionEvent.PointerProperties p = new MotionEvent.PointerProperties(); + p.id = pointers.get(i).pointerId; + switch (source) { + case InputDevice.SOURCE_TOUCHSCREEN: + p.toolType = MotionEvent.TOOL_TYPE_FINGER; + break; + case InputDevice.SOURCE_MOUSE: + p.toolType = MotionEvent.TOOL_TYPE_MOUSE; + break; + } + props[index++] = p; + } + } + return props; + } + + MotionEvent.PointerCoords[] getPointerCoords(int source) { + MotionEvent.PointerCoords[] coords = new MotionEvent.PointerCoords[getPointerCount(source)]; + int index = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + coords[index++] = pointers.get(i).getCoords(); + } + } + return coords; + } + } + + private void synthesizeNativePointer(int source, int pointerId, + int eventType, int screenX, int screenY, double pressure, + int orientation) + { + Log.d(LOGTAG, "Synthesizing pointer from " + source + " id " + pointerId + " at " + screenX + ", " + screenY); + + if (mPointerState == null) { + mPointerState = new SynthesizedEventState(); + } + + // Find the pointer if it already exists + int pointerIndex = mPointerState.getPointerIndex(pointerId); + + // Event-specific handling + switch (eventType) { + case MotionEvent.ACTION_POINTER_UP: + if (pointerIndex < 0) { + Log.d(LOGTAG, "Requested synthesis of a pointer-up for a pointer that doesn't exist!"); + return; + } + if (mPointerState.pointers.size() == 1) { + // Last pointer is going up + eventType = MotionEvent.ACTION_UP; + } + break; + case MotionEvent.ACTION_CANCEL: + if (pointerIndex < 0) { + Log.d(LOGTAG, "Requested synthesis of a pointer-cancel for a pointer that doesn't exist!"); + return; + } + break; + case MotionEvent.ACTION_POINTER_DOWN: + if (pointerIndex < 0) { + // Adding a new pointer + pointerIndex = mPointerState.addPointer(pointerId, source); + if (pointerIndex == 0) { + // first pointer + eventType = MotionEvent.ACTION_DOWN; + mPointerState.downTime = SystemClock.uptimeMillis(); + } + } else { + // We're moving an existing pointer + eventType = MotionEvent.ACTION_MOVE; + } + break; + case MotionEvent.ACTION_HOVER_MOVE: + if (pointerIndex < 0) { + // Mouse-move a pointer without it going "down". However + // in order to send the right MotionEvent without a lot of + // duplicated code, we add the pointer to mPointerState, + // and then remove it at the bottom of this function. + pointerIndex = mPointerState.addPointer(pointerId, source); + } else { + // We're moving an existing mouse pointer that went down. + eventType = MotionEvent.ACTION_MOVE; + } + break; + } + + // Update the pointer with the new info + PointerInfo info = mPointerState.pointers.get(pointerIndex); + info.screenX = screenX; + info.screenY = screenY; + info.pressure = pressure; + info.orientation = orientation; + + // Dispatch the event + int action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + action &= MotionEvent.ACTION_POINTER_INDEX_MASK; + action |= (eventType & MotionEvent.ACTION_MASK); + boolean isButtonDown = (source == InputDevice.SOURCE_MOUSE) && + (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE); + final MotionEvent event = MotionEvent.obtain( + /*downTime*/ mPointerState.downTime, + /*eventTime*/ SystemClock.uptimeMillis(), + /*action*/ action, + /*pointerCount*/ mPointerState.getPointerCount(source), + /*pointerProperties*/ mPointerState.getPointerProperties(source), + /*pointerCoords*/ mPointerState.getPointerCoords(source), + /*metaState*/ 0, + /*buttonState*/ (isButtonDown ? MotionEvent.BUTTON_PRIMARY : 0), + /*xPrecision*/ 0, + /*yPrecision*/ 0, + /*deviceId*/ 0, + /*edgeFlags*/ 0, + /*source*/ source, + /*flags*/ 0); + mView.post(new Runnable() { + @Override + public void run() { + mView.dispatchTouchEvent(event); + } + }); + + // Forget about removed pointers + if (eventType == MotionEvent.ACTION_POINTER_UP || + eventType == MotionEvent.ACTION_UP || + eventType == MotionEvent.ACTION_CANCEL || + eventType == MotionEvent.ACTION_HOVER_MOVE) + { + mPointerState.pointers.remove(pointerIndex); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void synthesizeNativeTouchPoint(int pointerId, int eventType, int screenX, + int screenY, double pressure, int orientation) + { + if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) { + throw new IllegalArgumentException("Use a different pointer ID in your test, this one is reserved for mouse"); + } + synthesizeNativePointer(InputDevice.SOURCE_TOUCHSCREEN, pointerId, + eventType, screenX, screenY, pressure, orientation); + } + + @WrapForJNI(calledFrom = "gecko") + public void synthesizeNativeMouseEvent(int eventType, int screenX, int screenY) { + synthesizeNativePointer(InputDevice.SOURCE_MOUSE, PointerInfo.RESERVED_MOUSE_POINTER_ID, + eventType, screenX, screenY, 0, 0); + } + + @WrapForJNI + public LayerRenderer.Frame createFrame() { + // Create the shaders and textures if necessary. + if (!mLayerRendererInitialized) { + if (mLayerRenderer == null) { + return null; + } + mLayerRenderer.createDefaultProgram(); + mLayerRendererInitialized = true; + } + + try { + return mLayerRenderer.createFrame(mFrameMetrics); + } catch (Exception e) { + Log.w(LOGTAG, e); + return null; + } + } + + private void geometryChanged() { + /* Let Gecko know if the screensize has changed */ + sendResizeEventIfNecessary(false, null); + } + + /** Implementation of LayerView.Listener */ + @Override + public void surfaceChanged(int width, int height) { + IntSize viewportSize = mToolbarAnimator.getViewportSize(); + setViewportSize(viewportSize.width, viewportSize.height, null); + } + + ImmutableViewportMetrics getViewportMetrics() { + return mViewportMetrics; + } + + /* + * You must hold the monitor while calling this. + */ + private void setViewportMetrics(ImmutableViewportMetrics metrics, boolean notifyGecko) { + // This class owns the viewport size and the fixed layer margins; don't let other pieces + // of code clobber either of them. The only place the viewport size should ever be + // updated is in GeckoLayerClient.setViewportSize, and the only place the margins should + // ever be updated is in GeckoLayerClient.setFixedLayerMargins; both of these assign to + // mViewportMetrics directly. + metrics = metrics.setViewportSize(mViewportMetrics.viewportRectWidth, mViewportMetrics.viewportRectHeight); + mViewportMetrics = metrics; + + viewportMetricsChanged(notifyGecko); + } + + /* + * You must hold the monitor while calling this. + */ + private void viewportMetricsChanged(boolean notifyGecko) { + mToolbarAnimator.onMetricsChanged(mViewportMetrics); + + mView.requestRender(); + if (notifyGecko && mGeckoIsReady) { + geometryChanged(); + } + } + + /* + * Updates the viewport metrics, overriding the viewport size and margins + * which are normally retained when calling setViewportMetrics. + * You must hold the monitor while calling this. + */ + void forceViewportMetrics(ImmutableViewportMetrics metrics, boolean notifyGecko, boolean forceRedraw) { + if (forceRedraw) { + mForceRedraw = true; + } + mViewportMetrics = metrics; + viewportMetricsChanged(notifyGecko); + } + + /** Implementation of PanZoomTarget */ + @Override + public void panZoomStopped() { + mToolbarAnimator.onPanZoomStopped(); + } + + Object getLock() { + return this; + } + + Matrix getMatrixForLayerRectToViewRect() { + if (!mGeckoIsReady) { + return null; + } + + ImmutableViewportMetrics viewportMetrics = mViewportMetrics; + PointF origin = viewportMetrics.getOrigin(); + float zoom = viewportMetrics.zoomFactor; + ImmutableViewportMetrics geckoViewport = mViewportMetrics; + PointF geckoOrigin = geckoViewport.getOrigin(); + float geckoZoom = geckoViewport.zoomFactor; + + Matrix matrix = new Matrix(); + matrix.postTranslate(geckoOrigin.x / geckoZoom, geckoOrigin.y / geckoZoom); + matrix.postScale(zoom, zoom); + matrix.postTranslate(-origin.x, -origin.y); + return matrix; + } + + @Override + public void setScrollingRootContent(boolean isRootContent) { + mToolbarAnimator.setScrollingRootContent(isRootContent); + } + + public void addDrawListener(DrawListener listener) { + mDrawListeners.add(listener); + } + + public void removeDrawListener(DrawListener listener) { + mDrawListeners.remove(listener); + } + + public void setClearColor(int color) { + mClearColor = color; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java new file mode 100644 index 000000000..8072deecc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ImmutableViewportMetrics.java @@ -0,0 +1,282 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.FloatUtils; + +import android.graphics.PointF; +import android.graphics.RectF; +import android.util.DisplayMetrics; + +/** + * ImmutableViewportMetrics are used to store the viewport metrics + * in way that we can access a version of them from multiple threads + * without having to take a lock + */ +public class ImmutableViewportMetrics { + + // We need to flatten the RectF and FloatSize structures + // because Java doesn't have the concept of const classes + public final float pageRectLeft; + public final float pageRectTop; + public final float pageRectRight; + public final float pageRectBottom; + public final float cssPageRectLeft; + public final float cssPageRectTop; + public final float cssPageRectRight; + public final float cssPageRectBottom; + public final float viewportRectLeft; + public final float viewportRectTop; + public final int viewportRectWidth; + public final int viewportRectHeight; + + public final float zoomFactor; + + public ImmutableViewportMetrics(DisplayMetrics metrics) { + viewportRectLeft = pageRectLeft = cssPageRectLeft = 0; + viewportRectTop = pageRectTop = cssPageRectTop = 0; + viewportRectWidth = metrics.widthPixels; + viewportRectHeight = metrics.heightPixels; + pageRectRight = cssPageRectRight = metrics.widthPixels; + pageRectBottom = cssPageRectBottom = metrics.heightPixels; + zoomFactor = 1.0f; + } + + /** This constructor is used by native code in AndroidJavaWrappers.cpp, be + * careful when modifying the signature. + */ + @WrapForJNI(calledFrom = "gecko") + private ImmutableViewportMetrics(float aPageRectLeft, float aPageRectTop, + float aPageRectRight, float aPageRectBottom, float aCssPageRectLeft, + float aCssPageRectTop, float aCssPageRectRight, float aCssPageRectBottom, + float aViewportRectLeft, float aViewportRectTop, int aViewportRectWidth, + int aViewportRectHeight, float aZoomFactor) + { + pageRectLeft = aPageRectLeft; + pageRectTop = aPageRectTop; + pageRectRight = aPageRectRight; + pageRectBottom = aPageRectBottom; + cssPageRectLeft = aCssPageRectLeft; + cssPageRectTop = aCssPageRectTop; + cssPageRectRight = aCssPageRectRight; + cssPageRectBottom = aCssPageRectBottom; + viewportRectLeft = aViewportRectLeft; + viewportRectTop = aViewportRectTop; + viewportRectWidth = aViewportRectWidth; + viewportRectHeight = aViewportRectHeight; + zoomFactor = aZoomFactor; + } + + public float getWidth() { + return viewportRectWidth; + } + + public float getHeight() { + return viewportRectHeight; + } + + public float viewportRectRight() { + return viewportRectLeft + viewportRectWidth; + } + + public float viewportRectBottom() { + return viewportRectTop + viewportRectHeight; + } + + public PointF getOrigin() { + return new PointF(viewportRectLeft, viewportRectTop); + } + + public FloatSize getSize() { + return new FloatSize(viewportRectWidth, viewportRectHeight); + } + + public RectF getViewport() { + return new RectF(viewportRectLeft, + viewportRectTop, + viewportRectRight(), + viewportRectBottom()); + } + + public RectF getCssViewport() { + return RectUtils.scale(getViewport(), 1 / zoomFactor); + } + + public RectF getPageRect() { + return new RectF(pageRectLeft, pageRectTop, pageRectRight, pageRectBottom); + } + + public float getPageWidth() { + return pageRectRight - pageRectLeft; + } + + public float getPageHeight() { + return pageRectBottom - pageRectTop; + } + + public RectF getCssPageRect() { + return new RectF(cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom); + } + + public RectF getOverscroll() { + return new RectF(Math.max(0, pageRectLeft - viewportRectLeft), + Math.max(0, pageRectTop - viewportRectTop), + Math.max(0, viewportRectRight() - pageRectRight), + Math.max(0, viewportRectBottom() - pageRectBottom)); + } + + /* + * Returns the viewport metrics that represent a linear transition between "this" and "to" at + * time "t", which is on the scale [0, 1). This function interpolates all values stored in + * the viewport metrics. + */ + public ImmutableViewportMetrics interpolate(ImmutableViewportMetrics to, float t) { + return new ImmutableViewportMetrics( + FloatUtils.interpolate(pageRectLeft, to.pageRectLeft, t), + FloatUtils.interpolate(pageRectTop, to.pageRectTop, t), + FloatUtils.interpolate(pageRectRight, to.pageRectRight, t), + FloatUtils.interpolate(pageRectBottom, to.pageRectBottom, t), + FloatUtils.interpolate(cssPageRectLeft, to.cssPageRectLeft, t), + FloatUtils.interpolate(cssPageRectTop, to.cssPageRectTop, t), + FloatUtils.interpolate(cssPageRectRight, to.cssPageRectRight, t), + FloatUtils.interpolate(cssPageRectBottom, to.cssPageRectBottom, t), + FloatUtils.interpolate(viewportRectLeft, to.viewportRectLeft, t), + FloatUtils.interpolate(viewportRectTop, to.viewportRectTop, t), + (int)FloatUtils.interpolate(viewportRectWidth, to.viewportRectWidth, t), + (int)FloatUtils.interpolate(viewportRectHeight, to.viewportRectHeight, t), + FloatUtils.interpolate(zoomFactor, to.zoomFactor, t)); + } + + public ImmutableViewportMetrics setViewportSize(int width, int height) { + if (width == viewportRectWidth && height == viewportRectHeight) { + return this; + } + + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + viewportRectLeft, viewportRectTop, width, height, + zoomFactor); + } + + public ImmutableViewportMetrics setViewportOrigin(float newOriginX, float newOriginY) { + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + newOriginX, newOriginY, viewportRectWidth, viewportRectHeight, + zoomFactor); + } + + public ImmutableViewportMetrics setZoomFactor(float newZoomFactor) { + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + viewportRectLeft, viewportRectTop, viewportRectWidth, viewportRectHeight, + newZoomFactor); + } + + public ImmutableViewportMetrics offsetViewportBy(float dx, float dy) { + return setViewportOrigin(viewportRectLeft + dx, viewportRectTop + dy); + } + + public ImmutableViewportMetrics offsetViewportByAndClamp(float dx, float dy) { + return setViewportOrigin( + Math.max(pageRectLeft, Math.min(viewportRectLeft + dx, pageRectRight - getWidth())), + Math.max(pageRectTop, Math.min(viewportRectTop + dy, pageRectBottom - getHeight()))); + } + + public ImmutableViewportMetrics setPageRect(RectF pageRect, RectF cssPageRect) { + return new ImmutableViewportMetrics( + pageRect.left, pageRect.top, pageRect.right, pageRect.bottom, + cssPageRect.left, cssPageRect.top, cssPageRect.right, cssPageRect.bottom, + viewportRectLeft, viewportRectTop, viewportRectWidth, viewportRectHeight, + zoomFactor); + } + + public ImmutableViewportMetrics setPageRectFrom(ImmutableViewportMetrics aMetrics) { + if (aMetrics.cssPageRectLeft == cssPageRectLeft && + aMetrics.cssPageRectTop == cssPageRectTop && + aMetrics.cssPageRectRight == cssPageRectRight && + aMetrics.cssPageRectBottom == cssPageRectBottom) { + return this; + } + RectF css = aMetrics.getCssPageRect(); + return setPageRect(RectUtils.scale(css, zoomFactor), css); + } + + /* This will set the zoom factor and re-scale page-size and viewport offset + * accordingly. The given focus will remain at the same point on the screen + * after scaling. + */ + public ImmutableViewportMetrics scaleTo(float newZoomFactor, PointF focus) { + // cssPageRect* is invariant, since we're setting the scale factor + // here. The page rect is based on the CSS page rect. + float newPageRectLeft = cssPageRectLeft * newZoomFactor; + float newPageRectTop = cssPageRectTop * newZoomFactor; + float newPageRectRight = cssPageRectLeft + ((cssPageRectRight - cssPageRectLeft) * newZoomFactor); + float newPageRectBottom = cssPageRectTop + ((cssPageRectBottom - cssPageRectTop) * newZoomFactor); + + PointF origin = getOrigin(); + origin.offset(focus.x, focus.y); + origin = PointUtils.scale(origin, newZoomFactor / zoomFactor); + origin.offset(-focus.x, -focus.y); + + return new ImmutableViewportMetrics( + newPageRectLeft, newPageRectTop, newPageRectRight, newPageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + origin.x, origin.y, viewportRectWidth, viewportRectHeight, + newZoomFactor); + } + + /** Clamps the viewport to remain within the page rect. */ + public ImmutableViewportMetrics clamp() { + RectF newViewport = getViewport(); + + // The viewport bounds ought to never exceed the page bounds. + if (newViewport.right > pageRectRight) + newViewport.offset((pageRectRight) - newViewport.right, 0); + if (newViewport.left < pageRectLeft) + newViewport.offset(pageRectLeft - newViewport.left, 0); + + if (newViewport.bottom > pageRectBottom) + newViewport.offset(0, (pageRectBottom) - newViewport.bottom); + if (newViewport.top < pageRectTop) + newViewport.offset(0, pageRectTop - newViewport.top); + + // Note that since newViewport is only translated around, the viewport's + // width and height are unchanged. + return new ImmutableViewportMetrics( + pageRectLeft, pageRectTop, pageRectRight, pageRectBottom, + cssPageRectLeft, cssPageRectTop, cssPageRectRight, cssPageRectBottom, + newViewport.left, newViewport.top, viewportRectWidth, viewportRectHeight, + zoomFactor); + } + + public boolean fuzzyEquals(ImmutableViewportMetrics other) { + // Don't bother checking the pageRectXXX values because they are a product + // of the cssPageRectXXX values and the zoomFactor, except with more rounding + // error. Checking those is both inefficient and can lead to false negatives. + return FloatUtils.fuzzyEquals(cssPageRectLeft, other.cssPageRectLeft) + && FloatUtils.fuzzyEquals(cssPageRectTop, other.cssPageRectTop) + && FloatUtils.fuzzyEquals(cssPageRectRight, other.cssPageRectRight) + && FloatUtils.fuzzyEquals(cssPageRectBottom, other.cssPageRectBottom) + && FloatUtils.fuzzyEquals(viewportRectLeft, other.viewportRectLeft) + && FloatUtils.fuzzyEquals(viewportRectTop, other.viewportRectTop) + && viewportRectWidth == other.viewportRectWidth + && viewportRectHeight == other.viewportRectHeight + && FloatUtils.fuzzyEquals(zoomFactor, other.zoomFactor); + } + + @Override + public String toString() { + return "ImmutableViewportMetrics v=(" + viewportRectLeft + "," + viewportRectTop + "," + + viewportRectWidth + "x" + viewportRectHeight + ") p=(" + pageRectLeft + "," + + pageRectTop + "," + pageRectRight + "," + pageRectBottom + ") c=(" + + cssPageRectLeft + "," + cssPageRectTop + "," + cssPageRectRight + "," + + cssPageRectBottom + ") z=" + zoomFactor; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/IntSize.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/IntSize.java new file mode 100644 index 000000000..0e847158d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/IntSize.java @@ -0,0 +1,89 @@ +/* -*- 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.gfx; + +import org.json.JSONException; +import org.json.JSONObject; + +public class IntSize { + public final int width, height; + + public IntSize(IntSize size) { width = size.width; height = size.height; } + public IntSize(int inWidth, int inHeight) { width = inWidth; height = inHeight; } + + public IntSize(FloatSize size) { + width = Math.round(size.width); + height = Math.round(size.height); + } + + public IntSize(JSONObject json) { + try { + width = json.getInt("width"); + height = json.getInt("height"); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + public int getArea() { + return width * height; + } + + public boolean equals(IntSize size) { + return ((size.width == width) && (size.height == height)); + } + + public boolean isPositive() { + return (width > 0 && height > 0); + } + + @Override + public String toString() { return "(" + width + "," + height + ")"; } + + public IntSize scale(float factor) { + return new IntSize(Math.round(width * factor), + Math.round(height * factor)); + } + + /* Returns the power of two that is greater than or equal to value */ + public static int nextPowerOfTwo(int value) { + // code taken from http://acius2.blogspot.com/2007/11/calculating-next-power-of-2.html + if (0 == value--) { + return 1; + } + value = (value >> 1) | value; + value = (value >> 2) | value; + value = (value >> 4) | value; + value = (value >> 8) | value; + value = (value >> 16) | value; + return value + 1; + } + + public IntSize nextPowerOfTwo() { + return new IntSize(nextPowerOfTwo(width), nextPowerOfTwo(height)); + } + + public static boolean isPowerOfTwo(int value) { + if (value == 0) + return false; + return (value & (value - 1)) == 0; + } + + public static int largestPowerOfTwoLessThan(float value) { + int val = (int) Math.floor(value); + if (val <= 0) { + throw new IllegalArgumentException("Error: value must be > 0"); + } + // keep dropping the least-significant set bits until only one is left + int bestVal = val; + while (val != 0) { + bestVal = val; + val &= (val - 1); + } + return bestVal; + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerRenderer.java new file mode 100644 index 000000000..1a087cc2a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerRenderer.java @@ -0,0 +1,275 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.mozglue.DirectBufferAllocator; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; +import android.opengl.GLES20; +import android.os.SystemClock; +import android.util.Log; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.FloatBuffer; +import java.nio.IntBuffer; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.ArrayList; +import java.util.List; + +import javax.microedition.khronos.egl.EGLConfig; + +/** + * The layer renderer implements the rendering logic for a layer view. + */ +public class LayerRenderer { + private static final String LOGTAG = "GeckoLayerRenderer"; + + /* + * The amount of time a frame is allowed to take to render before we declare it a dropped + * frame. + */ + private static final int MAX_FRAME_TIME = 16; /* 1000 ms / 60 FPS */ + private static final long NANOS_PER_MS = 1000000; + private static final int MAX_SCROLL_SPEED_TO_REQUEST_ZOOM_RENDER = 5; + + private final LayerView mView; + private ByteBuffer mCoordByteBuffer; + private FloatBuffer mCoordBuffer; + private int mMaxTextureSize; + + private long mLastFrameTime; + private final CopyOnWriteArrayList<RenderTask> mTasks; + + // Dropped frames display + private final int[] mFrameTimings; + private int mCurrentFrame, mFrameTimingsSum, mDroppedFrames; + + private IntBuffer mPixelBuffer; + private List<LayerView.ZoomedViewListener> mZoomedViewListeners; + private float mLastViewLeft; + private float mLastViewTop; + + public LayerRenderer(LayerView view) { + mView = view; + + mTasks = new CopyOnWriteArrayList<RenderTask>(); + mLastFrameTime = System.nanoTime(); + + mFrameTimings = new int[60]; + mCurrentFrame = mFrameTimingsSum = mDroppedFrames = 0; + + mZoomedViewListeners = new ArrayList<LayerView.ZoomedViewListener>(); + } + + public void destroy() { + if (mCoordByteBuffer != null) { + DirectBufferAllocator.free(mCoordByteBuffer); + mCoordByteBuffer = null; + mCoordBuffer = null; + } + mZoomedViewListeners.clear(); + } + + void onSurfaceCreated(EGLConfig config) { + createDefaultProgram(); + } + + public void createDefaultProgram() { + int maxTextureSizeResult[] = new int[1]; + GLES20.glGetIntegerv(GLES20.GL_MAX_TEXTURE_SIZE, maxTextureSizeResult, 0); + mMaxTextureSize = maxTextureSizeResult[0]; + } + + public int getMaxTextureSize() { + return mMaxTextureSize; + } + + public void postRenderTask(RenderTask aTask) { + mTasks.add(aTask); + mView.requestRender(); + } + + public void removeRenderTask(RenderTask aTask) { + mTasks.remove(aTask); + } + + private void runRenderTasks(CopyOnWriteArrayList<RenderTask> tasks, boolean after, long frameStartTime) { + for (RenderTask task : tasks) { + if (task.runAfter != after) { + continue; + } + + boolean stillRunning = task.run(frameStartTime - mLastFrameTime, frameStartTime); + + // Remove the task from the list if its finished + if (!stillRunning) { + tasks.remove(task); + } + } + } + + /** Used by robocop for testing purposes. Not for production use! */ + IntBuffer getPixels() { + IntBuffer pixelBuffer = IntBuffer.allocate(mView.getWidth() * mView.getHeight()); + synchronized (pixelBuffer) { + mPixelBuffer = pixelBuffer; + mView.requestRender(); + try { + pixelBuffer.wait(); + } catch (InterruptedException ie) { + } + mPixelBuffer = null; + } + return pixelBuffer; + } + + private void updateDroppedFrames(long frameStartTime) { + int frameElapsedTime = (int)((System.nanoTime() - frameStartTime) / NANOS_PER_MS); + + /* Update the running statistics. */ + mFrameTimingsSum -= mFrameTimings[mCurrentFrame]; + mFrameTimingsSum += frameElapsedTime; + mDroppedFrames -= (mFrameTimings[mCurrentFrame] + 1) / MAX_FRAME_TIME; + mDroppedFrames += (frameElapsedTime + 1) / MAX_FRAME_TIME; + + mFrameTimings[mCurrentFrame] = frameElapsedTime; + mCurrentFrame = (mCurrentFrame + 1) % mFrameTimings.length; + + int averageTime = mFrameTimingsSum / mFrameTimings.length; + } + + public Frame createFrame(ImmutableViewportMetrics metrics) { + return new Frame(metrics); + } + + public class Frame { + // The timestamp recording the start of this frame. + private long mFrameStartTime; + // A fixed snapshot of the viewport metrics that this frame is using to render content. + private final ImmutableViewportMetrics mFrameMetrics; + + public Frame(ImmutableViewportMetrics metrics) { + mFrameMetrics = metrics; + } + + /** This function is invoked via JNI; be careful when modifying signature. */ + @WrapForJNI + public void beginDrawing() { + mFrameStartTime = System.nanoTime(); + + // Run through pre-render tasks + runRenderTasks(mTasks, false, mFrameStartTime); + } + + + private void maybeRequestZoomedViewRender() { + // Concurrently update of mZoomedViewListeners should not be an issue here + // because the following line is just a short-circuit + if (mZoomedViewListeners.size() == 0) { + return; + } + + // When scrolling fast, do not request zoomed view render to avoid to slow down + // the scroll in the main view. + // Speed is estimated using the offset changes between 2 display frame calls + final float viewLeft = Math.round(mFrameMetrics.getViewport().left); + final float viewTop = Math.round(mFrameMetrics.getViewport().top); + boolean shouldWaitToRender = false; + + if (Math.abs(mLastViewLeft - viewLeft) > MAX_SCROLL_SPEED_TO_REQUEST_ZOOM_RENDER || + Math.abs(mLastViewTop - viewTop) > MAX_SCROLL_SPEED_TO_REQUEST_ZOOM_RENDER) { + shouldWaitToRender = true; + } + + mLastViewLeft = viewLeft; + mLastViewTop = viewTop; + + if (shouldWaitToRender) { + return; + } + + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + for (LayerView.ZoomedViewListener listener : mZoomedViewListeners) { + listener.requestZoomedViewRender(); + } + } + }); + } + + + /** This function is invoked via JNI; be careful when modifying signature. */ + @WrapForJNI + public void endDrawing() { + PanningPerfAPI.recordFrameTime(); + + runRenderTasks(mTasks, true, mFrameStartTime); + maybeRequestZoomedViewRender(); + + /* Used by robocop for testing purposes */ + IntBuffer pixelBuffer = mPixelBuffer; + if (pixelBuffer != null) { + synchronized (pixelBuffer) { + pixelBuffer.position(0); + GLES20.glReadPixels(0, 0, Math.round(mFrameMetrics.getWidth()), + Math.round(mFrameMetrics.getHeight()), GLES20.GL_RGBA, + GLES20.GL_UNSIGNED_BYTE, pixelBuffer); + pixelBuffer.notify(); + } + } + + // Remove background color once we've painted. GeckoLayerClient is + // responsible for setting this flag before current document is + // composited. + if (mView.getPaintState() == LayerView.PAINT_BEFORE_FIRST) { + mView.post(new Runnable() { + @Override + public void run() { + mView.setSurfaceBackgroundColor(Color.TRANSPARENT); + } + }); + mView.setPaintState(LayerView.PAINT_AFTER_FIRST); + } + mLastFrameTime = mFrameStartTime; + } + } + + public void updateZoomedView(final ByteBuffer data) { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + for (LayerView.ZoomedViewListener listener : mZoomedViewListeners) { + data.position(0); + listener.updateView(data); + } + } + }); + } + + public void addZoomedViewListener(LayerView.ZoomedViewListener listener) { + ThreadUtils.assertOnUiThread(); + mZoomedViewListeners.add(listener); + } + + public void removeZoomedViewListener(LayerView.ZoomedViewListener listener) { + ThreadUtils.assertOnUiThread(); + mZoomedViewListeners.remove(listener); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java new file mode 100644 index 000000000..969aa3f24 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/LayerView.java @@ -0,0 +1,711 @@ +/* -*- 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.gfx; + +import java.nio.ByteBuffer; +import java.nio.IntBuffer; + +import org.mozilla.gecko.AndroidGamepadManager; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.AppConstants.Versions; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAccessibility; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.ThreadUtils; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.SurfaceTexture; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.view.InputDevice; +import android.widget.FrameLayout; + +/** + * A view rendered by the layer compositor. + */ +public class LayerView extends FrameLayout { + private static final String LOGTAG = "GeckoLayerView"; + + private GeckoLayerClient mLayerClient; + private PanZoomController mPanZoomController; + private DynamicToolbarAnimator mToolbarAnimator; + private LayerRenderer mRenderer; + /* Must be a PAINT_xxx constant */ + private int mPaintState; + private FullScreenState mFullScreenState; + + private SurfaceView mSurfaceView; + private TextureView mTextureView; + + private Listener mListener; + + /* This should only be modified on the Java UI thread. */ + private final Overscroll mOverscroll; + + private boolean mServerSurfaceValid; + private int mWidth, mHeight; + + private boolean onAttachedToWindowCalled; + + /* This is written by the Gecko thread and the UI thread, and read by the UI thread. */ + @WrapForJNI(stubName = "CompositorCreated", calledFrom = "ui") + /* package */ volatile boolean mCompositorCreated; + + private class Compositor extends JNIObject { + public Compositor() { + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + @Override protected native void disposeNative(); + + // Gecko thread sets its Java instances; does not block UI thread. + @WrapForJNI(calledFrom = "any", dispatchTo = "gecko") + /* package */ native void attachToJava(GeckoLayerClient layerClient, + NativePanZoomController npzc); + + @WrapForJNI(calledFrom = "any", dispatchTo = "gecko") + /* package */ native void onSizeChanged(int windowWidth, int windowHeight, + int screenWidth, int screenHeight); + + // Gecko thread creates compositor; blocks UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "proxy") + /* package */ native void createCompositor(int width, int height, Object surface); + + // Gecko thread pauses compositor; blocks UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + /* package */ native void syncPauseCompositor(); + + // UI thread resumes compositor and notifies Gecko thread; does not block UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + /* package */ native void syncResumeResizeCompositor(int width, int height, Object surface); + + @WrapForJNI(calledFrom = "any", dispatchTo = "current") + /* package */ native void syncInvalidateAndScheduleComposite(); + + @WrapForJNI(calledFrom = "gecko") + private void reattach() { + mCompositorCreated = true; + } + + @WrapForJNI(calledFrom = "gecko") + private void destroy() { + // The nsWindow has been closed. First mark our compositor as destroyed. + LayerView.this.mCompositorCreated = false; + + LayerView.this.mLayerClient.setGeckoReady(false); + + // Then clear out any pending calls on the UI thread by disposing on the UI thread. + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + disposeNative(); + } + }); + } + } + + private final Compositor mCompositor = new Compositor(); + + /* Flags used to determine when to show the painted surface. */ + public static final int PAINT_START = 0; + public static final int PAINT_BEFORE_FIRST = 1; + public static final int PAINT_AFTER_FIRST = 2; + + public boolean shouldUseTextureView() { + // Disable TextureView support for now as it causes panning/zooming + // performance regressions (see bug 792259). Uncomment the code below + // once this bug is fixed. + return false; + + /* + // we can only use TextureView on ICS or higher + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) { + Log.i(LOGTAG, "Not using TextureView: not on ICS+"); + return false; + } + + try { + // and then we can only use it if we have a hardware accelerated window + Method m = View.class.getMethod("isHardwareAccelerated", (Class[]) null); + return (Boolean) m.invoke(this); + } catch (Exception e) { + Log.i(LOGTAG, "Not using TextureView: caught exception checking for hw accel: " + e.toString()); + return false; + } */ + } + + public LayerView(Context context, AttributeSet attrs) { + super(context, attrs); + + mPaintState = PAINT_START; + mFullScreenState = FullScreenState.NONE; + + mOverscroll = new OverscrollEdgeEffect(this); + } + + public LayerView(Context context) { + this(context, null); + } + + public void initializeView(EventDispatcher eventDispatcher) { + mLayerClient = new GeckoLayerClient(getContext(), this, eventDispatcher); + if (mOverscroll != null) { + mLayerClient.setOverscrollHandler(mOverscroll); + } + + mPanZoomController = mLayerClient.getPanZoomController(); + mToolbarAnimator = mLayerClient.getDynamicToolbarAnimator(); + + mRenderer = new LayerRenderer(this); + + setFocusable(true); + setFocusableInTouchMode(true); + + GeckoAccessibility.setDelegate(this); + } + + /** + * MotionEventHelper dragAsync() robocop tests can instruct + * PanZoomController not to generate longpress events. + */ + public void setIsLongpressEnabled(boolean isLongpressEnabled) { + mPanZoomController.setIsLongpressEnabled(isLongpressEnabled); + } + + private static Point getEventRadius(MotionEvent event) { + return new Point((int)event.getToolMajor() / 2, + (int)event.getToolMinor() / 2); + } + + public void showSurface() { + // Fix this if TextureView support is turned back on above + mSurfaceView.setVisibility(View.VISIBLE); + } + + public void hideSurface() { + // Fix this if TextureView support is turned back on above + mSurfaceView.setVisibility(View.INVISIBLE); + } + + public void destroy() { + if (mLayerClient != null) { + mLayerClient.destroy(); + } + if (mRenderer != null) { + mRenderer.destroy(); + } + } + + @Override + public void dispatchDraw(final Canvas canvas) { + super.dispatchDraw(canvas); + + // We must have a layer client to get valid viewport metrics + if (mLayerClient != null && mOverscroll != null) { + mOverscroll.draw(canvas, getViewportMetrics()); + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mToolbarAnimator != null && mToolbarAnimator.onInterceptTouchEvent(event)) { + if (mPanZoomController != null) { + mPanZoomController.onMotionEventVelocity(event.getEventTime(), mToolbarAnimator.getVelocity()); + } + return true; + } + if (!mLayerClient.isGeckoReady()) { + // If gecko isn't loaded yet, don't try sending events to the + // native code because it's just going to crash + return true; + } + if (mPanZoomController != null && mPanZoomController.onTouchEvent(event)) { + return true; + } + return false; + } + + @Override + public boolean onHoverEvent(MotionEvent event) { + // If we get a touchscreen hover event, and accessibility is not enabled, + // don't send it to gecko. + if (event.getSource() == InputDevice.SOURCE_TOUCHSCREEN && + !GeckoAccessibility.isEnabled()) { + return false; + } + + if (!mLayerClient.isGeckoReady()) { + // If gecko isn't loaded yet, don't try sending events to the + // native code because it's just going to crash + return true; + } else if (mPanZoomController != null && mPanZoomController.onMotionEvent(event)) { + return true; + } + + return false; + } + + @Override + public boolean onGenericMotionEvent(MotionEvent event) { + if (AndroidGamepadManager.handleMotionEvent(event)) { + return true; + } + if (!mLayerClient.isGeckoReady()) { + // If gecko isn't loaded yet, don't try sending events to the + // native code because it's just going to crash + return true; + } + if (mPanZoomController != null && mPanZoomController.onMotionEvent(event)) { + return true; + } + return false; + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (onAttachedToWindowCalled) { + attachCompositor(); + } + super.onRestoreInstanceState(state); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + + // We are adding descendants to this LayerView, but we don't want the + // descendants to affect the way LayerView retains its focus. + setDescendantFocusability(FOCUS_BLOCK_DESCENDANTS); + + // This check should not be done before the view is attached to a window + // as hardware acceleration will not be enabled at that point. + // We must create and add the SurfaceView instance before the view tree + // is fully created to avoid flickering (see bug 801477). + if (shouldUseTextureView()) { + mTextureView = new TextureView(getContext()); + mTextureView.setSurfaceTextureListener(new SurfaceTextureListener()); + + // The background is set to this color when the LayerView is + // created, and it will be shown immediately at startup. Shortly + // after, the tab's background color will be used before any content + // is shown. + mTextureView.setBackgroundColor(Color.WHITE); + addView(mTextureView, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } else { + // This will stop PropertyAnimator from creating a drawing cache (i.e. a bitmap) + // from a SurfaceView, which is just not possible (the bitmap will be transparent). + setWillNotCacheDrawing(false); + + mSurfaceView = new LayerSurfaceView(getContext(), this); + mSurfaceView.setBackgroundColor(Color.WHITE); + addView(mSurfaceView, new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + SurfaceHolder holder = mSurfaceView.getHolder(); + holder.addCallback(new SurfaceListener()); + } + + attachCompositor(); + + onAttachedToWindowCalled = true; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + onAttachedToWindowCalled = false; + } + + // Don't expose GeckoLayerClient to things outside this package; only expose it as an Object + GeckoLayerClient getLayerClient() { return mLayerClient; } + + public PanZoomController getPanZoomController() { return mPanZoomController; } + public DynamicToolbarAnimator getDynamicToolbarAnimator() { return mToolbarAnimator; } + + public ImmutableViewportMetrics getViewportMetrics() { + return mLayerClient.getViewportMetrics(); + } + + public Matrix getMatrixForLayerRectToViewRect() { + return mLayerClient.getMatrixForLayerRectToViewRect(); + } + + public void setSurfaceBackgroundColor(int newColor) { + if (mSurfaceView != null) { + mSurfaceView.setBackgroundColor(newColor); + } + } + + public void requestRender() { + if (mCompositorCreated) { + mCompositor.syncInvalidateAndScheduleComposite(); + } + } + + public void postRenderTask(RenderTask task) { + mRenderer.postRenderTask(task); + } + + public void removeRenderTask(RenderTask task) { + mRenderer.removeRenderTask(task); + } + + public int getMaxTextureSize() { + return mRenderer.getMaxTextureSize(); + } + + /** Used by robocop for testing purposes. Not for production use! */ + @RobocopTarget + public IntBuffer getPixels() { + return mRenderer.getPixels(); + } + + /* paintState must be a PAINT_xxx constant. */ + public void setPaintState(int paintState) { + mPaintState = paintState; + } + + public int getPaintState() { + return mPaintState; + } + + public LayerRenderer getRenderer() { + return mRenderer; + } + + public void setListener(Listener listener) { + mListener = listener; + } + + Listener getListener() { + return mListener; + } + + private void attachCompositor() { + final NativePanZoomController npzc = (NativePanZoomController) mPanZoomController; + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + mCompositor.attachToJava(mLayerClient, npzc); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, + mCompositor, "attachToJava", + GeckoLayerClient.class, mLayerClient, + NativePanZoomController.class, npzc); + } + } + + @WrapForJNI(calledFrom = "ui") + protected Object getCompositor() { + return mCompositor; + } + + void serverSurfaceChanged(int newWidth, int newHeight) { + ThreadUtils.assertOnUiThread(); + + mWidth = newWidth; + mHeight = newHeight; + mServerSurfaceValid = true; + + updateCompositor(); + } + + void updateCompositor() { + ThreadUtils.assertOnUiThread(); + + if (mCompositorCreated) { + // If the compositor has already been created, just resume it instead. We don't need + // to block here because if the surface is destroyed before the compositor grabs it, + // we can handle that gracefully (i.e. the compositor will remain paused). + if (!mServerSurfaceValid) { + return; + } + // Asking Gecko to resume the compositor takes too long (see + // https://bugzilla.mozilla.org/show_bug.cgi?id=735230#c23), so we + // resume the compositor directly. We still need to inform Gecko about + // the compositor resuming, so that Gecko knows that it can now draw. + // It is important to not notify Gecko until after the compositor has + // been resumed, otherwise Gecko may send updates that get dropped. + mCompositor.syncResumeResizeCompositor(mWidth, mHeight, getSurface()); + return; + } + + // Only try to create the compositor if we have a valid surface and gecko is up. When these + // two conditions are satisfied, we can be relatively sure that the compositor creation will + // happen without needing to block anywhere. + if (mServerSurfaceValid && getLayerClient().isGeckoReady()) { + mCompositorCreated = true; + mCompositor.createCompositor(mWidth, mHeight, getSurface()); + } + } + + /* When using a SurfaceView (mSurfaceView != null), resizing happens in two + * phases. First, the LayerView changes size, then, often some frames later, + * the SurfaceView changes size. Because of this, we need to split the + * resize into two phases to avoid jittering. + * + * The first phase is the LayerView size change. mListener is notified so + * that a synchronous draw can be performed (otherwise a blank frame will + * appear). + * + * The second phase is the SurfaceView size change. At this point, the + * backing GL surface is resized and another synchronous draw is performed. + * Gecko is also sent the new window size, and this will likely cause an + * extra draw a few frames later, after it's re-rendered and caught up. + * + * In the case that there is no valid GL surface (for example, when + * resuming, or when coming back from the awesomescreen), or we're using a + * TextureView instead of a SurfaceView, the first phase is skipped. + */ + private void onSizeChanged(int width, int height) { + if (!mServerSurfaceValid || mSurfaceView == null) { + surfaceChanged(width, height); + return; + } + + if (mCompositorCreated) { + mCompositor.syncResumeResizeCompositor(width, height, getSurface()); + } + + if (mOverscroll != null) { + mOverscroll.setSize(width, height); + } + } + + private void surfaceChanged(int width, int height) { + serverSurfaceChanged(width, height); + + if (mListener != null) { + mListener.surfaceChanged(width, height); + } + + if (mOverscroll != null) { + mOverscroll.setSize(width, height); + } + } + + void notifySizeChanged(int windowWidth, int windowHeight, int screenWidth, int screenHeight) { + mCompositor.onSizeChanged(windowWidth, windowHeight, screenWidth, screenHeight); + } + + void serverSurfaceDestroyed() { + ThreadUtils.assertOnUiThread(); + + // We need to coordinate with Gecko when pausing composition, to ensure + // that Gecko never executes a draw event while the compositor is paused. + // This is sent synchronously to make sure that we don't attempt to use + // any outstanding Surfaces after we call this (such as from a + // serverSurfaceDestroyed notification), and to make sure that any in-flight + // Gecko draw events have been processed. When this returns, composition is + // definitely paused -- it'll synchronize with the Gecko event loop, which + // in turn will synchronize with the compositor thread. + if (mCompositorCreated) { + mCompositor.syncPauseCompositor(); + } + + mServerSurfaceValid = false; + } + + private void onDestroyed() { + serverSurfaceDestroyed(); + } + + public Object getNativeWindow() { + if (mSurfaceView != null) + return mSurfaceView.getHolder(); + + return mTextureView.getSurfaceTexture(); + } + + public Object getSurface() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurface(); + } + return null; + } + + // This method is called on the Gecko main thread. + @WrapForJNI(calledFrom = "gecko") + private static void updateZoomedView(ByteBuffer data) { + LayerView layerView = GeckoAppShell.getLayerView(); + if (layerView != null) { + LayerRenderer layerRenderer = layerView.getRenderer(); + if (layerRenderer != null) { + layerRenderer.updateZoomedView(data); + } + } + } + + public interface Listener { + void surfaceChanged(int width, int height); + } + + private class SurfaceListener implements SurfaceHolder.Callback { + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, + int height) { + onSizeChanged(width, height); + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + onDestroyed(); + } + } + + /* A subclass of SurfaceView to listen to layout changes, as + * View.OnLayoutChangeListener requires API level 11. + */ + private class LayerSurfaceView extends SurfaceView { + private LayerView mParent; + + public LayerSurfaceView(Context aContext, LayerView aParent) { + super(aContext); + mParent = aParent; + } + + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + super.onLayout(changed, left, top, right, bottom); + if (changed && mParent.mServerSurfaceValid) { + mParent.surfaceChanged(right - left, bottom - top); + } + } + } + + private class SurfaceTextureListener implements TextureView.SurfaceTextureListener { + @Override + public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) { + // We don't do this for surfaceCreated above because it is always followed by a surfaceChanged, + // but that is not the case here. + onSizeChanged(width, height); + } + + @Override + public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) { + onDestroyed(); + return true; // allow Android to call release() on the SurfaceTexture, we are done drawing to it + } + + @Override + public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { + onSizeChanged(width, height); + } + + @Override + public void onSurfaceTextureUpdated(SurfaceTexture surface) { + + } + } + + @RobocopTarget + public void addDrawListener(DrawListener listener) { + mLayerClient.addDrawListener(listener); + } + + @RobocopTarget + public void removeDrawListener(DrawListener listener) { + mLayerClient.removeDrawListener(listener); + } + + @RobocopTarget + public static interface DrawListener { + public void drawFinished(); + } + + @Override + public void setOverScrollMode(int overscrollMode) { + super.setOverScrollMode(overscrollMode); + } + + @Override + public int getOverScrollMode() { + return super.getOverScrollMode(); + } + + public float getZoomFactor() { + return getLayerClient().getViewportMetrics().zoomFactor; + } + + @Override + public void onFocusChanged (boolean gainFocus, int direction, Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + GeckoAccessibility.onLayerViewFocusChanged(gainFocus); + } + + public void setFullScreenState(FullScreenState state) { + mFullScreenState = state; + } + + public boolean isFullScreen() { + return mFullScreenState != FullScreenState.NONE; + } + + public void setMaxTranslation(float aMaxTranslation) { + mToolbarAnimator.setMaxTranslation(aMaxTranslation); + } + + public void setSurfaceTranslation(float translation) { + setTranslationY(translation); + } + + public float getSurfaceTranslation() { + return getTranslationY(); + } + + // Public hooks for dynamic toolbar translation + + public interface DynamicToolbarListener { + public void onTranslationChanged(float aToolbarTranslation, float aLayerViewTranslation); + public void onPanZoomStopped(); + public void onMetricsChanged(ImmutableViewportMetrics viewport); + } + + // Public hooks for zoomed view + + public interface ZoomedViewListener { + public void requestZoomedViewRender(); + public void updateView(ByteBuffer data); + } + + public void addZoomedViewListener(ZoomedViewListener listener) { + mRenderer.addZoomedViewListener(listener); + } + + public void removeZoomedViewListener(ZoomedViewListener listener) { + mRenderer.removeZoomedViewListener(listener); + } + + public void setClearColor(int color) { + if (mLayerClient != null) { + mLayerClient.setClearColor(color); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/NativePanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/NativePanZoomController.java new file mode 100644 index 000000000..6b643c85b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/NativePanZoomController.java @@ -0,0 +1,300 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.PrefsHelper; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.DynamicToolbarAnimator.PinReason; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.ThreadUtils; + +import org.json.JSONObject; + +import android.graphics.PointF; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.InputDevice; + +class NativePanZoomController extends JNIObject implements PanZoomController { + private final PanZoomTarget mTarget; + private final LayerView mView; + private boolean mDestroyed; + private Overscroll mOverscroll; + boolean mNegateWheelScroll; + private float mPointerScrollFactor; + private PrefsHelper.PrefHandler mPrefsObserver; + private long mLastDownTime; + private static final float MAX_SCROLL = 0.075f * GeckoAppShell.getDpi(); + + @WrapForJNI(calledFrom = "ui") + private native boolean handleMotionEvent( + int action, int actionIndex, long time, int metaState, + int pointerId[], float x[], float y[], float orientation[], float pressure[], + float toolMajor[], float toolMinor[]); + + @WrapForJNI(calledFrom = "ui") + private native boolean handleScrollEvent( + long time, int metaState, + float x, float y, + float hScroll, float vScroll); + + @WrapForJNI(calledFrom = "ui") + private native boolean handleMouseEvent( + int action, long time, int metaState, + float x, float y, int buttons); + + @WrapForJNI(calledFrom = "ui") + private native void handleMotionEventVelocity(long time, float ySpeed); + + private boolean handleMotionEvent(MotionEvent event) { + if (mDestroyed) { + return false; + } + + final int action = event.getActionMasked(); + final int count = event.getPointerCount(); + + if (action == MotionEvent.ACTION_DOWN) { + mLastDownTime = event.getDownTime(); + } else if (mLastDownTime != event.getDownTime()) { + return false; + } + + final int[] pointerId = new int[count]; + final float[] x = new float[count]; + final float[] y = new float[count]; + final float[] orientation = new float[count]; + final float[] pressure = new float[count]; + final float[] toolMajor = new float[count]; + final float[] toolMinor = new float[count]; + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + + for (int i = 0; i < count; i++) { + pointerId[i] = event.getPointerId(i); + event.getPointerCoords(i, coords); + + x[i] = coords.x; + y[i] = coords.y; + + orientation[i] = coords.orientation; + pressure[i] = coords.pressure; + + // If we are converting to CSS pixels, we should adjust the radii as well. + toolMajor[i] = coords.toolMajor; + toolMinor[i] = coords.toolMinor; + } + + return handleMotionEvent(action, event.getActionIndex(), event.getEventTime(), + event.getMetaState(), pointerId, x, y, orientation, pressure, + toolMajor, toolMinor); + } + + private boolean handleScrollEvent(MotionEvent event) { + if (mDestroyed) { + return false; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return false; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + final float x = coords.x; + final float y = coords.y; + + final float flipFactor = mNegateWheelScroll ? -1.0f : 1.0f; + final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * flipFactor * mPointerScrollFactor; + final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * flipFactor * mPointerScrollFactor; + + return handleScrollEvent(event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll); + } + + private boolean handleMouseEvent(MotionEvent event) { + if (mDestroyed) { + return false; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return false; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + final float x = coords.x; + final float y = coords.y; + + return handleMouseEvent(event.getActionMasked(), event.getEventTime(), event.getMetaState(), x, y, event.getButtonState()); + } + + + NativePanZoomController(PanZoomTarget target, View view) { + mTarget = target; + mView = (LayerView) view; + mDestroyed = true; + + String[] prefs = { "ui.scrolling.negate_wheel_scroll" }; + mPrefsObserver = new PrefsHelper.PrefHandlerBase() { + @Override public void prefValue(String pref, boolean value) { + if (pref.equals("ui.scrolling.negate_wheel_scroll")) { + mNegateWheelScroll = value; + } + } + }; + PrefsHelper.addObserver(prefs, mPrefsObserver); + + TypedValue outValue = new TypedValue(); + if (view.getContext().getTheme().resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) { + mPointerScrollFactor = outValue.getDimension(view.getContext().getResources().getDisplayMetrics()); + } else { + mPointerScrollFactor = MAX_SCROLL; + } + } + + @Override + public boolean onTouchEvent(MotionEvent event) { +// NOTE: This commented out block of code allows Fennec to generate +// mouse event instead of converting them to touch events. +// This gives Fennec similar behaviour to desktop when using +// a mouse. +// +// if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) { +// return handleMouseEvent(event); +// } else { +// return handleMotionEvent(event); +// } + return handleMotionEvent(event); + } + + @Override + public boolean onMotionEvent(MotionEvent event) { + final int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_SCROLL) { + if (event.getDownTime() >= mLastDownTime) { + mLastDownTime = event.getDownTime(); + } else if ((InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD) { + return false; + } + return handleScrollEvent(event); + } else if ((action == MotionEvent.ACTION_HOVER_MOVE) || + (action == MotionEvent.ACTION_HOVER_ENTER) || + (action == MotionEvent.ACTION_HOVER_EXIT)) { + return handleMouseEvent(event); + } else { + return false; + } + } + + @Override + public void onMotionEventVelocity(final long aEventTime, final float aSpeedY) { + handleMotionEventVelocity(aEventTime, aSpeedY); + } + + @Override @WrapForJNI(calledFrom = "ui") // PanZoomController + public void destroy() { + if (mPrefsObserver != null) { + PrefsHelper.removeObserver(mPrefsObserver); + mPrefsObserver = null; + } + if (mDestroyed || !mTarget.isGeckoReady()) { + return; + } + mDestroyed = true; + disposeNative(); + } + + @Override + public void attach() { + mDestroyed = false; + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") @Override // JNIObject + protected native void disposeNative(); + + @Override + public void setOverscrollHandler(final Overscroll handler) { + mOverscroll = handler; + } + + @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread. + private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled); + + @Override // PanZoomController + public void setIsLongpressEnabled(boolean isLongpressEnabled) { + if (!mDestroyed) { + nativeSetIsLongpressEnabled(isLongpressEnabled); + } + } + + @WrapForJNI(calledFrom = "ui") + private native void adjustScrollForSurfaceShift(float aX, float aY); + + @Override // PanZoomController + public ImmutableViewportMetrics adjustScrollForSurfaceShift(ImmutableViewportMetrics aMetrics, PointF aShift) { + adjustScrollForSurfaceShift(aShift.x, aShift.y); + return aMetrics.offsetViewportByAndClamp(aShift.x, aShift.y); + } + + @WrapForJNI + private void updateOverscrollVelocity(final float x, final float y) { + if (mOverscroll != null) { + if (ThreadUtils.isOnUiThread() == true) { + mOverscroll.setVelocity(x * 1000.0f, Overscroll.Axis.X); + mOverscroll.setVelocity(y * 1000.0f, Overscroll.Axis.Y); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + // Multiply the velocity by 1000 to match what was done in JPZ. + mOverscroll.setVelocity(x * 1000.0f, Overscroll.Axis.X); + mOverscroll.setVelocity(y * 1000.0f, Overscroll.Axis.Y); + } + }); + } + } + } + + @WrapForJNI + private void updateOverscrollOffset(final float x, final float y) { + if (mOverscroll != null) { + if (ThreadUtils.isOnUiThread() == true) { + mOverscroll.setDistance(x, Overscroll.Axis.X); + mOverscroll.setDistance(y, Overscroll.Axis.Y); + } else { + ThreadUtils.postToUiThread(new Runnable() { + @Override + public void run() { + mOverscroll.setDistance(x, Overscroll.Axis.X); + mOverscroll.setDistance(y, Overscroll.Axis.Y); + } + }); + } + } + } + + @WrapForJNI(calledFrom = "ui") + private void setScrollingRootContent(final boolean isRootContent) { + mTarget.setScrollingRootContent(isRootContent); + } + + /** + * Active SelectionCaretDrag requires DynamicToolbarAnimator to be pinned + * to avoid unwanted scroll interactions. + */ + @WrapForJNI(calledFrom = "gecko") + private void onSelectionDragState(boolean state) { + mView.getDynamicToolbarAnimator().setPinned(state, PinReason.CARET_DRAG); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/Overscroll.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/Overscroll.java new file mode 100644 index 000000000..e442444d5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/Overscroll.java @@ -0,0 +1,21 @@ +/* -*- 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.gfx; + +import android.graphics.Canvas; + +public interface Overscroll { + // The axis to show overscroll on. + public enum Axis { + X, + Y, + }; + + public void draw(final Canvas canvas, final ImmutableViewportMetrics metrics); + public void setSize(final int width, final int height); + public void setVelocity(final float velocity, final Axis axis); + public void setDistance(final float distance, final Axis axis); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/OverscrollEdgeEffect.java new file mode 100644 index 000000000..85e04d9f2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/OverscrollEdgeEffect.java @@ -0,0 +1,162 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PointF; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.widget.EdgeEffect; + +import java.lang.reflect.Field; + +public class OverscrollEdgeEffect implements Overscroll { + // Used to index particular edges in the edges array + private static final int TOP = 0; + private static final int BOTTOM = 1; + private static final int LEFT = 2; + private static final int RIGHT = 3; + + // All four edges of the screen + private final EdgeEffect[] mEdges = new EdgeEffect[4]; + + // The view we're showing this overscroll on. + private final LayerView mView; + + public OverscrollEdgeEffect(final LayerView v) { + Field paintField = null; + if (Versions.feature21Plus) { + try { + paintField = EdgeEffect.class.getDeclaredField("mPaint"); + paintField.setAccessible(true); + } catch (NoSuchFieldException e) { + } + } + + mView = v; + Context context = v.getContext(); + for (int i = 0; i < 4; i++) { + mEdges[i] = new EdgeEffect(context); + + try { + if (paintField != null) { + final Paint p = (Paint) paintField.get(mEdges[i]); + + // The Android EdgeEffect class uses a mode of SRC_ATOP here, which means it will only + // draw the effect where there are non-transparent pixels in the destination. Since the LayerView + // itself is fully transparent, it doesn't display at all. We need to use SRC instead. + p.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC)); + } + } catch (IllegalAccessException e) { + } + } + } + + @Override + public void setSize(final int width, final int height) { + mEdges[LEFT].setSize(height, width); + mEdges[RIGHT].setSize(height, width); + mEdges[TOP].setSize(width, height); + mEdges[BOTTOM].setSize(width, height); + } + + private EdgeEffect getEdgeForAxisAndSide(final Axis axis, final float side) { + if (axis == Axis.Y) { + if (side < 0) { + return mEdges[TOP]; + } else { + return mEdges[BOTTOM]; + } + } else { + if (side < 0) { + return mEdges[LEFT]; + } else { + return mEdges[RIGHT]; + } + } + } + + private void invalidate() { + if (Versions.feature16Plus) { + mView.postInvalidateOnAnimation(); + } else { + mView.postInvalidateDelayed(10); + } + } + + @Override + public void setVelocity(final float velocity, final Axis axis) { + final EdgeEffect edge = getEdgeForAxisAndSide(axis, velocity); + + // If we're showing overscroll already, start fading it out. + if (!edge.isFinished()) { + edge.onRelease(); + } else { + // Otherwise, show an absorb effect + edge.onAbsorb((int)velocity); + } + + invalidate(); + } + + @Override + public void setDistance(final float distance, final Axis axis) { + // The first overscroll event often has zero distance. Throw it out + if (distance == 0.0f) { + return; + } + + final EdgeEffect edge = getEdgeForAxisAndSide(axis, (int)distance); + edge.onPull(distance / (axis == Axis.X ? mView.getWidth() : mView.getHeight())); + invalidate(); + } + + @Override + public void draw(final Canvas canvas, final ImmutableViewportMetrics metrics) { + if (metrics == null) { + return; + } + + PointF visibleEnd = mView.getDynamicToolbarAnimator().getVisibleEndOfLayerView(); + + // If we're pulling an edge, or fading it out, draw! + boolean invalidate = false; + if (!mEdges[TOP].isFinished()) { + invalidate |= draw(mEdges[TOP], canvas, 0, 0, 0); + } + + if (!mEdges[BOTTOM].isFinished()) { + invalidate |= draw(mEdges[BOTTOM], canvas, visibleEnd.x, visibleEnd.y, 180); + } + + if (!mEdges[LEFT].isFinished()) { + invalidate |= draw(mEdges[LEFT], canvas, 0, visibleEnd.y, 270); + } + + if (!mEdges[RIGHT].isFinished()) { + invalidate |= draw(mEdges[RIGHT], canvas, visibleEnd.x, 0, 90); + } + + // If the edge effect is animating off screen, invalidate. + if (invalidate) { + invalidate(); + } + } + + private static boolean draw(final EdgeEffect edge, final Canvas canvas, final float translateX, final float translateY, final float rotation) { + final int state = canvas.save(); + canvas.translate(translateX, translateY); + canvas.rotate(rotation); + boolean invalidate = edge.draw(canvas); + canvas.restoreToCount(state); + + return invalidate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java new file mode 100644 index 000000000..fbd07c69b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomController.java @@ -0,0 +1,38 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.EventDispatcher; + +import android.graphics.PointF; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +public interface PanZoomController { + // Threshold for sending touch move events to content + public static final float CLICK_THRESHOLD = 1 / 50f * GeckoAppShell.getDpi(); + + static class Factory { + static PanZoomController create(PanZoomTarget target, View view, EventDispatcher dispatcher) { + return new NativePanZoomController(target, view); + } + } + + public void destroy(); + public void attach(); + + public boolean onTouchEvent(MotionEvent event); + public boolean onMotionEvent(MotionEvent event); + public void onMotionEventVelocity(final long aEventTime, final float aSpeedY); + + public void setOverscrollHandler(final Overscroll controller); + + public void setIsLongpressEnabled(boolean isLongpressEnabled); + + public ImmutableViewportMetrics adjustScrollForSurfaceShift(ImmutableViewportMetrics aMetrics, PointF aShift); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomTarget.java new file mode 100644 index 000000000..0896674fc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanZoomTarget.java @@ -0,0 +1,15 @@ +/* -*- 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.gfx; + +import android.graphics.Matrix; +import android.graphics.PointF; + +public interface PanZoomTarget { + public void panZoomStopped(); + public boolean isGeckoReady(); + public void setScrollingRootContent(boolean isRootContent); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java new file mode 100644 index 000000000..42eb2b88b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java @@ -0,0 +1,73 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.os.SystemClock; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; + +public class PanningPerfAPI { + private static final String LOGTAG = "GeckoPanningPerfAPI"; + + // make this large enough to avoid having to resize the frame time + // list, as that may be expensive and impact the thing we're trying + // to measure. + private static final int EXPECTED_FRAME_COUNT = 2048; + + private static boolean mRecordingFrames; + private static List<Long> mFrameTimes; + private static long mFrameStartTime; + + private static void initialiseRecordingArrays() { + if (mFrameTimes == null) { + mFrameTimes = new ArrayList<Long>(EXPECTED_FRAME_COUNT); + } else { + mFrameTimes.clear(); + } + } + + @RobocopTarget + public static void startFrameTimeRecording() { + if (mRecordingFrames) { + Log.e(LOGTAG, "Error: startFrameTimeRecording() called while already recording!"); + return; + } + mRecordingFrames = true; + initialiseRecordingArrays(); + mFrameStartTime = SystemClock.uptimeMillis(); + } + + @RobocopTarget + public static List<Long> stopFrameTimeRecording() { + if (!mRecordingFrames) { + Log.e(LOGTAG, "Error: stopFrameTimeRecording() called when not recording!"); + return null; + } + mRecordingFrames = false; + return mFrameTimes; + } + + public static void recordFrameTime() { + // this will be called often, so try to make it as quick as possible + if (mRecordingFrames) { + mFrameTimes.add(SystemClock.uptimeMillis() - mFrameStartTime); + } + } + + @RobocopTarget + public static void startCheckerboardRecording() { + throw new UnsupportedOperationException(); + } + + @RobocopTarget + public static List<Float> stopCheckerboardRecording() { + throw new UnsupportedOperationException(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PointUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PointUtils.java new file mode 100644 index 000000000..8db329c9f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PointUtils.java @@ -0,0 +1,51 @@ +/* -*- 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.gfx; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.graphics.Point; +import android.graphics.PointF; + +public final class PointUtils { + public static PointF add(PointF one, PointF two) { + return new PointF(one.x + two.x, one.y + two.y); + } + + public static PointF subtract(PointF one, PointF two) { + return new PointF(one.x - two.x, one.y - two.y); + } + + public static PointF scale(PointF point, float factor) { + return new PointF(point.x * factor, point.y * factor); + } + + public static Point round(PointF point) { + return new Point(Math.round(point.x), Math.round(point.y)); + } + + /* Computes the magnitude of the given vector. */ + public static float distance(PointF point) { + return (float)Math.sqrt(point.x * point.x + point.y * point.y); + } + + /** Computes the scalar distance between two points. */ + public static float distance(PointF one, PointF two) { + return PointF.length(one.x - two.x, one.y - two.y); + } + + public static JSONObject toJSON(PointF point) throws JSONException { + // Ensure we put ints, not longs, because Gecko message handlers call getInt(). + int x = Math.round(point.x); + int y = Math.round(point.y); + JSONObject json = new JSONObject(); + json.put("x", x); + json.put("y", y); + return json; + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ProgressiveUpdateData.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ProgressiveUpdateData.java new file mode 100644 index 000000000..d961a2569 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ProgressiveUpdateData.java @@ -0,0 +1,29 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * This is the data structure that's returned by the progressive tile update + * callback function. It encompasses the current viewport and a boolean value + * representing whether the front-end is interested in the current progressive + * update continuing. + */ +@WrapForJNI +public class ProgressiveUpdateData { + public float x; + public float y; + public float scale; + public boolean abort; + + public void setViewport(ImmutableViewportMetrics viewport) { + this.x = viewport.viewportRectLeft; + this.y = viewport.viewportRectTop; + this.scale = viewport.zoomFactor; + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RectUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RectUtils.java new file mode 100644 index 000000000..22151db76 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RectUtils.java @@ -0,0 +1,126 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.util.FloatUtils; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; + +public final class RectUtils { + private RectUtils() {} + + public static Rect create(JSONObject json) { + try { + int x = json.getInt("x"); + int y = json.getInt("y"); + int width = json.getInt("width"); + int height = json.getInt("height"); + return new Rect(x, y, x + width, y + height); + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + public static String toJSON(RectF rect) { + StringBuilder sb = new StringBuilder(256); + sb.append("{ \"left\": ").append(rect.left) + .append(", \"top\": ").append(rect.top) + .append(", \"right\": ").append(rect.right) + .append(", \"bottom\": ").append(rect.bottom) + .append('}'); + return sb.toString(); + } + + public static RectF expand(RectF rect, float moreWidth, float moreHeight) { + float halfMoreWidth = moreWidth / 2; + float halfMoreHeight = moreHeight / 2; + return new RectF(rect.left - halfMoreWidth, + rect.top - halfMoreHeight, + rect.right + halfMoreWidth, + rect.bottom + halfMoreHeight); + } + + public static RectF contract(RectF rect, float lessWidth, float lessHeight) { + float halfLessWidth = lessWidth / 2.0f; + float halfLessHeight = lessHeight / 2.0f; + return new RectF(rect.left + halfLessWidth, + rect.top + halfLessHeight, + rect.right - halfLessWidth, + rect.bottom - halfLessHeight); + } + + public static RectF intersect(RectF one, RectF two) { + float left = Math.max(one.left, two.left); + float top = Math.max(one.top, two.top); + float right = Math.min(one.right, two.right); + float bottom = Math.min(one.bottom, two.bottom); + return new RectF(left, top, Math.max(right, left), Math.max(bottom, top)); + } + + public static RectF scale(RectF rect, float scale) { + float x = rect.left * scale; + float y = rect.top * scale; + return new RectF(x, y, + x + (rect.width() * scale), + y + (rect.height() * scale)); + } + + public static RectF scaleAndRound(RectF rect, float scale) { + float left = rect.left * scale; + float top = rect.top * scale; + return new RectF(Math.round(left), + Math.round(top), + Math.round(left + (rect.width() * scale)), + Math.round(top + (rect.height() * scale))); + } + + /** Returns the nearest integer rect of the given rect. */ + public static Rect round(RectF rect) { + Rect r = new Rect(); + round(rect, r); + return r; + } + + public static void round(RectF rect, Rect dest) { + dest.set(Math.round(rect.left), Math.round(rect.top), + Math.round(rect.right), Math.round(rect.bottom)); + } + + public static Rect roundIn(RectF rect) { + return new Rect((int)Math.ceil(rect.left), (int)Math.ceil(rect.top), + (int)Math.floor(rect.right), (int)Math.floor(rect.bottom)); + } + + public static IntSize getSize(Rect rect) { + return new IntSize(rect.width(), rect.height()); + } + + public static Point getOrigin(Rect rect) { + return new Point(rect.left, rect.top); + } + + public static PointF getOrigin(RectF rect) { + return new PointF(rect.left, rect.top); + } + + public static boolean fuzzyEquals(RectF a, RectF b) { + if (a == null && b == null) + return true; + else if ((a == null && b != null) || (a != null && b == null)) + return false; + else + return FloatUtils.fuzzyEquals(a.top, b.top) + && FloatUtils.fuzzyEquals(a.left, b.left) + && FloatUtils.fuzzyEquals(a.right, b.right) + && FloatUtils.fuzzyEquals(a.bottom, b.bottom); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RenderTask.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RenderTask.java new file mode 100644 index 000000000..80cbf77f0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RenderTask.java @@ -0,0 +1,80 @@ +/* -*- 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.gfx; + +/** + * A class used to schedule a callback to occur when the next frame is drawn. + * Subclasses must redefine the internalRun method, not the run method. + */ +public abstract class RenderTask { + /** + * Whether to run the task after the render, or before. + */ + public final boolean runAfter; + + /** + * Time when this task has first run, in ns. Useful for tasks which run for a specific duration. + */ + private long mStartTime; + + /** + * Whether we should initialise mStartTime on the next frame run. + */ + private boolean mResetStartTime = true; + + /** + * The callback to run on each frame. timeDelta is the time elapsed since + * the last call, in nanoseconds. Returns true if it should continue + * running, or false if it should be removed from the task queue. Returning + * true implicitly schedules a redraw. + * + * This method first initializes the start time if resetStartTime has been invoked, + * then calls internalRun. + * + * Note : subclasses should override internalRun. + * + * @param timeDelta the time between the beginning of last frame and the beginning of this frame, in ns. + * @param currentFrameStartTime the startTime of the current frame, in ns. + * @return true if animation should be run at the next frame, false otherwise + * @see RenderTask#internalRun(long, long) + */ + public final boolean run(long timeDelta, long currentFrameStartTime) { + if (mResetStartTime) { + mStartTime = currentFrameStartTime; + mResetStartTime = false; + } + return internalRun(timeDelta, currentFrameStartTime); + } + + /** + * Abstract method to be overridden by subclasses. + * @param timeDelta the time between the beginning of last frame and the beginning of this frame, in ns + * @param currentFrameStartTime the startTime of the current frame, in ns. + * @return true if animation should be run at the next frame, false otherwise + */ + protected abstract boolean internalRun(long timeDelta, long currentFrameStartTime); + + public RenderTask(boolean aRunAfter) { + runAfter = aRunAfter; + } + + /** + * Get the start time of this task. + * It is the start time of the first frame this task was run on. + * @return the start time in ns + */ + public long getStartTime() { + return mStartTime; + } + + /** + * Schedule a reset of the recorded start time next time {@link RenderTask#run(long, long)} is run. + * @see RenderTask#getStartTime() + */ + public void resetStartTime() { + mResetStartTime = true; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/StackScroller.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/StackScroller.java new file mode 100644 index 000000000..293268cba --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/StackScroller.java @@ -0,0 +1,695 @@ +// Copyright 2015 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package org.mozilla.gecko.gfx; + +import android.content.Context; +import android.hardware.SensorManager; +import android.util.Log; +import android.view.ViewConfiguration; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * This class is vastly copied from {@link android.widget.OverScroller} but decouples the time + * from the app time so it can be specified manually. + */ +@WrapForJNI(exceptionMode = "nsresult") +public class StackScroller { + private int mMode; + + private final SplineStackScroller mScrollerX; + private final SplineStackScroller mScrollerY; + + private final boolean mFlywheel; + + private static final int SCROLL_MODE = 0; + private static final int FLING_MODE = 1; + + private static float sViscousFluidScale; + private static float sViscousFluidNormalize; + + /** + * Creates an StackScroller with a viscous fluid scroll interpolator and flywheel. + * @param context + */ + public StackScroller(Context context) { + mFlywheel = true; + mScrollerX = new SplineStackScroller(context); + mScrollerY = new SplineStackScroller(context); + initContants(); + } + + private static void initContants() { + // This controls the viscous fluid effect (how much of it) + sViscousFluidScale = 8.0f; + // must be set to 1.0 (used in viscousFluid()) + sViscousFluidNormalize = 1.0f; + sViscousFluidNormalize = 1.0f / viscousFluid(1.0f); + } + + /** + * + * Returns whether the scroller has finished scrolling. + * + * @return True if the scroller has finished scrolling, false otherwise. + */ + public final boolean isFinished() { + return mScrollerX.mFinished && mScrollerY.mFinished; + } + + /** + * Force the finished field to a particular value. Contrary to + * {@link #abortAnimation()}, forcing the animation to finished + * does NOT cause the scroller to move to the final x and y + * position. + * + * @param finished The new finished value. + */ + public final void forceFinished(boolean finished) { + mScrollerX.mFinished = mScrollerY.mFinished = finished; + } + + /** + * Returns the current X offset in the scroll. + * + * @return The new X offset as an absolute distance from the origin. + */ + public final int getCurrX() { + return mScrollerX.mCurrentPosition; + } + + /** + * Returns the current Y offset in the scroll. + * + * @return The new Y offset as an absolute distance from the origin. + */ + public final int getCurrY() { + return mScrollerY.mCurrentPosition; + } + + /** + * Returns where the scroll will end. Valid only for "fling" scrolls. + * + * @return The final X offset as an absolute distance from the origin. + */ + public final int getFinalX() { + return mScrollerX.mFinal; + } + + public final float getCurrSpeedX() { + return mScrollerX.mCurrVelocity; + } + + public final float getCurrSpeedY() { + return mScrollerY.mCurrVelocity; + } + + /** + * Returns where the scroll will end. Valid only for "fling" scrolls. + * + * @return The final Y offset as an absolute distance from the origin. + */ + public final int getFinalY() { + return mScrollerY.mFinal; + } + + /** + * Sets where the scroll will end. Valid only for "fling" scrolls. + * + * @param x The final X offset as an absolute distance from the origin. + */ + public final void setFinalX(int x) { + mScrollerX.setFinalPosition(x); + } + + private static float viscousFluid(float x) { + x *= sViscousFluidScale; + if (x < 1.0f) { + x -= (1.0f - (float) Math.exp(-x)); + } else { + float start = 0.36787944117f; // 1/e == exp(-1) + x = 1.0f - (float) Math.exp(1.0f - x); + x = start + x * (1.0f - start); + } + x *= sViscousFluidNormalize; + return x; + } + + /** + * Call this when you want to know the new location. If it returns true, the + * animation is not yet finished. + */ + public boolean computeScrollOffset(long time) { + if (isFinished()) { + return false; + } + + switch (mMode) { + case SCROLL_MODE: + // Any scroller can be used for time, since they were started + // together in scroll mode. We use X here. + final long elapsedTime = time - mScrollerX.mStartTime; + + final int duration = mScrollerX.mDuration; + if (elapsedTime < duration) { + float q = (float) (elapsedTime) / duration; + q = viscousFluid(q); + mScrollerX.updateScroll(q); + mScrollerY.updateScroll(q); + } else { + abortAnimation(); + } + break; + + case FLING_MODE: + if (!mScrollerX.mFinished) { + if (!mScrollerX.update(time)) { + if (!mScrollerX.continueWhenFinished(time)) { + mScrollerX.finish(); + } + } + } + + if (!mScrollerY.mFinished) { + if (!mScrollerY.update(time)) { + if (!mScrollerY.continueWhenFinished(time)) { + mScrollerY.finish(); + } + } + } + + break; + + default: + break; + } + + return true; + } + + /** + * Start scrolling by providing a starting point and the distance to travel. + * + * @param startX Starting horizontal scroll offset in pixels. Positive + * numbers will scroll the content to the left. + * @param startY Starting vertical scroll offset in pixels. Positive numbers + * will scroll the content up. + * @param dx Horizontal distance to travel. Positive numbers will scroll the + * content to the left. + * @param dy Vertical distance to travel. Positive numbers will scroll the + * content up. + * @param duration Duration of the scroll in milliseconds. + */ + public void startScroll(int startX, int startY, int dx, int dy, long startTime, int duration) { + mMode = SCROLL_MODE; + mScrollerX.startScroll(startX, dx, startTime, duration); + mScrollerY.startScroll(startY, dy, startTime, duration); + } + + /** + * Call this when you want to 'spring back' into a valid coordinate range. + * + * @param startX Starting X coordinate + * @param startY Starting Y coordinate + * @param minX Minimum valid X value + * @param maxX Maximum valid X value + * @param minY Minimum valid Y value + * @param maxY Minimum valid Y value + * @return true if a springback was initiated, false if startX and startY were + * already within the valid range. + */ + public boolean springBack( + int startX, int startY, int minX, int maxX, int minY, int maxY, long time) { + mMode = FLING_MODE; + + // Make sure both methods are called. + final boolean spingbackX = mScrollerX.springback(startX, minX, maxX, time); + final boolean spingbackY = mScrollerY.springback(startY, minY, maxY, time); + return spingbackX || spingbackY; + } + + /** + * Start scrolling based on a fling gesture. The distance traveled will + * depend on the initial velocity of the fling. + * + * @param startX Starting point of the scroll (X) + * @param startY Starting point of the scroll (Y) + * @param velocityX Initial velocity of the fling (X) measured in pixels per second. + * @param velocityY Initial velocity of the fling (Y) measured in pixels per second + * @param minX Minimum X value. The scroller will not scroll past this point + * unless overX > 0. If overfling is allowed, it will use minX as + * a springback boundary. + * @param maxX Maximum X value. The scroller will not scroll past this point + * unless overX > 0. If overfling is allowed, it will use maxX as + * a springback boundary. + * @param minY Minimum Y value. The scroller will not scroll past this point + * unless overY > 0. If overfling is allowed, it will use minY as + * a springback boundary. + * @param maxY Maximum Y value. The scroller will not scroll past this point + * unless overY > 0. If overfling is allowed, it will use maxY as + * a springback boundary. + * @param overX Overfling range. If > 0, horizontal overfling in either + * direction will be possible. + * @param overY Overfling range. If > 0, vertical overfling in either + * direction will be possible. + */ + public void fling(int startX, int startY, int velocityX, int velocityY, int minX, int maxX, + int minY, int maxY, int overX, int overY, long time) { + // Continue a scroll or fling in progress + if (mFlywheel && !isFinished()) { + float oldVelocityX = mScrollerX.mCurrVelocity; + float oldVelocityY = mScrollerY.mCurrVelocity; + boolean sameXDirection = (velocityX == 0) || (oldVelocityX == 0) || + ((velocityX < 0) == (oldVelocityX < 0)); + boolean sameYDirection = (velocityY == 0) || (oldVelocityY == 0) || + ((velocityY < 0) == (oldVelocityY < 0)); + if (sameXDirection) { + velocityX += oldVelocityX; + } + if (sameYDirection) { + velocityY += oldVelocityY; + } + } + + mMode = FLING_MODE; + mScrollerX.fling(startX, velocityX, minX, maxX, overX, time); + mScrollerY.fling(startY, velocityY, minY, maxY, overY, time); + } + + /** + * Stops the animation. Contrary to {@link #forceFinished(boolean)}, + * aborting the animating causes the scroller to move to the final x and y + * positions. + * + * @see #forceFinished(boolean) + */ + public void abortAnimation() { + mScrollerX.finish(); + mScrollerY.finish(); + } + + static class SplineStackScroller { + // Initial position + private int mStart; + + // Current position + private int mCurrentPosition; + + // Final position + private int mFinal; + + // Initial velocity + private int mVelocity; + + // Current velocity + private float mCurrVelocity; + + // Constant current deceleration + private float mDeceleration; + + // Animation starting time, in system milliseconds + private long mStartTime; + + // Animation duration, in milliseconds + private int mDuration; + + // Duration to complete spline component of animation + private int mSplineDuration; + + // Distance to travel along spline animation + private int mSplineDistance; + + // Whether the animation is currently in progress + private boolean mFinished; + + // The allowed overshot distance before boundary is reached. + private int mOver; + + // Fling friction + private final float mFlingFriction = ViewConfiguration.getScrollFriction(); + + // Current state of the animation. + private int mState = SPLINE; + + // Constant gravity value, used in the deceleration phase. + private static final float GRAVITY = 2000.0f; + + // A context-specific coefficient adjusted to physical values. + private final float mPhysicalCoeff; + + private static final float DECELERATION_RATE = (float) (Math.log(0.78) / Math.log(0.9)); + private static final float INFLEXION = 0.35f; // Tension lines cross at (INFLEXION, 1) + private static final float START_TENSION = 0.5f; + private static final float END_TENSION = 1.0f; + private static final float P1 = START_TENSION * INFLEXION; + private static final float P2 = 1.0f - END_TENSION * (1.0f - INFLEXION); + + private static final int NB_SAMPLES = 100; + private static final float[] SPLINE_POSITION = new float[NB_SAMPLES + 1]; + private static final float[] SPLINE_TIME = new float[NB_SAMPLES + 1]; + + private static final int SPLINE = 0; + private static final int CUBIC = 1; + private static final int BALLISTIC = 2; + + static { + float xMin = 0.0f; + float yMin = 0.0f; + for (int i = 0; i < NB_SAMPLES; i++) { + final float alpha = (float) i / NB_SAMPLES; + + float xMax = 1.0f; + float x, tx, coef; + while (true) { + x = xMin + (xMax - xMin) / 2.0f; + coef = 3.0f * x * (1.0f - x); + tx = coef * ((1.0f - x) * P1 + x * P2) + x * x * x; + if (Math.abs(tx - alpha) < 1E-5) break; + if (tx > alpha) { + xMax = x; + } else { + xMin = x; + } + } + SPLINE_POSITION[i] = coef * ((1.0f - x) * START_TENSION + x) + x * x * x; + + float yMax = 1.0f; + float y, dy; + while (true) { + y = yMin + (yMax - yMin) / 2.0f; + coef = 3.0f * y * (1.0f - y); + dy = coef * ((1.0f - y) * START_TENSION + y) + y * y * y; + if (Math.abs(dy - alpha) < 1E-5) break; + if (dy > alpha) { + yMax = y; + } else { + yMin = y; + } + } + SPLINE_TIME[i] = coef * ((1.0f - y) * P1 + y * P2) + y * y * y; + } + SPLINE_POSITION[NB_SAMPLES] = SPLINE_TIME[NB_SAMPLES] = 1.0f; + } + + SplineStackScroller(Context context) { + mFinished = true; + final float ppi = context.getResources().getDisplayMetrics().density * 160.0f; + mPhysicalCoeff = SensorManager.GRAVITY_EARTH // g (m/s^2) + * 39.37f // inch/meter + * ppi * 0.84f; // look and feel tuning + } + + void updateScroll(float q) { + mCurrentPosition = mStart + Math.round(q * (mFinal - mStart)); + } + + /* + * Get a signed deceleration that will reduce the velocity. + */ + private static float getDeceleration(int velocity) { + return velocity > 0 ? -GRAVITY : GRAVITY; + } + + /* + * Modifies mDuration to the duration it takes to get from start to newFinal using the + * spline interpolation. The previous duration was needed to get to oldFinal. + */ + private void adjustDuration(int start, int oldFinal, int newFinal) { + final int oldDistance = oldFinal - start; + final int newDistance = newFinal - start; + final float x = Math.abs((float) newDistance / oldDistance); + final int index = (int) (NB_SAMPLES * x); + if (index < NB_SAMPLES) { + final float xInf = (float) index / NB_SAMPLES; + final float xSup = (float) (index + 1) / NB_SAMPLES; + final float tInf = SPLINE_TIME[index]; + final float tSup = SPLINE_TIME[index + 1]; + final float timeCoef = tInf + (x - xInf) / (xSup - xInf) * (tSup - tInf); + mDuration *= timeCoef; + } + } + + void startScroll(int start, int distance, long startTime, int duration) { + mFinished = false; + + mStart = start; + mFinal = start + distance; + + mStartTime = startTime; + mDuration = duration; + + // Unused + mDeceleration = 0.0f; + mVelocity = 0; + } + + void finish() { + mCurrentPosition = mFinal; + // Not reset since WebView relies on this value for fast fling. + // TODO: restore when WebView uses the fast fling implemented in this class. + // mCurrVelocity = 0.0f; + mFinished = true; + } + + void setFinalPosition(int position) { + mFinal = position; + mFinished = false; + } + + boolean springback(int start, int min, int max, long time) { + mFinished = true; + + mStart = mFinal = start; + mVelocity = 0; + + mStartTime = time; + mDuration = 0; + + if (start < min) { + startSpringback(start, min, 0); + } else if (start > max) { + startSpringback(start, max, 0); + } + + return !mFinished; + } + + private void startSpringback(int start, int end, int velocity) { + // mStartTime has been set + mFinished = false; + mState = CUBIC; + mStart = start; + mFinal = end; + final int delta = start - end; + mDeceleration = getDeceleration(delta); + // TODO take velocity into account + mVelocity = -delta; // only sign is used + mOver = Math.abs(delta); + mDuration = (int) (1000.0 * Math.sqrt(-2.0 * delta / mDeceleration)); + } + + void fling(int start, int velocity, int min, int max, int over, long time) { + mOver = over; + mFinished = false; + mCurrVelocity = mVelocity = velocity; + mDuration = mSplineDuration = 0; + mStartTime = time; + mCurrentPosition = mStart = start; + + if (start > max || start < min) { + startAfterEdge(start, min, max, velocity, time); + return; + } + + mState = SPLINE; + double totalDistance = 0.0; + + if (velocity != 0) { + mDuration = mSplineDuration = getSplineFlingDuration(velocity); + totalDistance = getSplineFlingDistance(velocity); + } + + mSplineDistance = (int) (totalDistance * Math.signum(velocity)); + mFinal = start + mSplineDistance; + + // Clamp to a valid final position + if (mFinal < min) { + adjustDuration(mStart, mFinal, min); + mFinal = min; + } + + if (mFinal > max) { + adjustDuration(mStart, mFinal, max); + mFinal = max; + } + } + + private double getSplineDeceleration(int velocity) { + return Math.log(INFLEXION * Math.abs(velocity) / (mFlingFriction * mPhysicalCoeff)); + } + + private double getSplineFlingDistance(int velocity) { + final double l = getSplineDeceleration(velocity); + final double decelMinusOne = DECELERATION_RATE - 1.0; + return mFlingFriction * mPhysicalCoeff + * Math.exp(DECELERATION_RATE / decelMinusOne * l); + } + + /* Returns the duration, expressed in milliseconds */ + private int getSplineFlingDuration(int velocity) { + final double l = getSplineDeceleration(velocity); + final double decelMinusOne = DECELERATION_RATE - 1.0; + return (int) (1000.0 * Math.exp(l / decelMinusOne)); + } + + private void fitOnBounceCurve(int start, int end, int velocity) { + // Simulate a bounce that started from edge + final float durationToApex = -velocity / mDeceleration; + final float distanceToApex = velocity * velocity / 2.0f / Math.abs(mDeceleration); + final float distanceToEdge = Math.abs(end - start); + final float totalDuration = (float) Math.sqrt( + 2.0 * (distanceToApex + distanceToEdge) / Math.abs(mDeceleration)); + mStartTime -= (int) (1000.0f * (totalDuration - durationToApex)); + mStart = end; + mVelocity = (int) (-mDeceleration * totalDuration); + } + + private void startBounceAfterEdge(int start, int end, int velocity) { + mDeceleration = getDeceleration(velocity == 0 ? start - end : velocity); + fitOnBounceCurve(start, end, velocity); + onEdgeReached(); + } + + private void startAfterEdge(int start, int min, int max, int velocity, long time) { + if (start > min && start < max) { + Log.e("StackScroller", "startAfterEdge called from a valid position"); + mFinished = true; + return; + } + final boolean positive = start > max; + final int edge = positive ? max : min; + final int overDistance = start - edge; + boolean keepIncreasing = overDistance * velocity >= 0; + if (keepIncreasing) { + // Will result in a bounce or a to_boundary depending on velocity. + startBounceAfterEdge(start, edge, velocity); + } else { + final double totalDistance = getSplineFlingDistance(velocity); + if (totalDistance > Math.abs(overDistance)) { + fling(start, velocity, positive ? min : start, positive ? start : max, mOver, + time); + } else { + startSpringback(start, edge, velocity); + } + } + } + + private void onEdgeReached() { + // mStart, mVelocity and mStartTime were adjusted to their values when edge was reached. + float distance = mVelocity * mVelocity / (2.0f * Math.abs(mDeceleration)); + final float sign = Math.signum(mVelocity); + + if (distance > mOver) { + // Default deceleration is not sufficient to slow us down before boundary + mDeceleration = -sign * mVelocity * mVelocity / (2.0f * mOver); + distance = mOver; + } + + mOver = (int) distance; + mState = BALLISTIC; + mFinal = mStart + (int) (mVelocity > 0 ? distance : -distance); + mDuration = -(int) (1000.0f * mVelocity / mDeceleration); + } + + boolean continueWhenFinished(long time) { + switch (mState) { + case SPLINE: + // Duration from start to null velocity + if (mDuration < mSplineDuration) { + // If the animation was clamped, we reached the edge + mStart = mFinal; + // TODO Better compute speed when edge was reached + mVelocity = (int) mCurrVelocity; + mDeceleration = getDeceleration(mVelocity); + mStartTime += mDuration; + onEdgeReached(); + } else { + // Normal stop, no need to continue + return false; + } + break; + case BALLISTIC: + mStartTime += mDuration; + startSpringback(mFinal, mStart, 0); + break; + case CUBIC: + return false; + } + + update(time); + return true; + } + + /* + * Update the current position and velocity for current time. Returns + * true if update has been done and false if animation duration has been + * reached. + */ + boolean update(long time) { + final long currentTime = time - mStartTime; + + if (((mState == SPLINE) && (mSplineDuration <= 0)) || + ((mState == CUBIC) && (mDuration <= 0))) { + return false; + } + + if (currentTime > mDuration) { + return false; + } + + double distance = 0.0; + switch (mState) { + case SPLINE: { + final float t = (float) currentTime / mSplineDuration; + final int index = (int) (NB_SAMPLES * t); + float distanceCoef = 1.f; + float velocityCoef = 0.f; + if (index < NB_SAMPLES) { + final float tInf = (float) index / NB_SAMPLES; + final float tSup = (float) (index + 1) / NB_SAMPLES; + final float dInf = SPLINE_POSITION[index]; + final float dSup = SPLINE_POSITION[index + 1]; + velocityCoef = (dSup - dInf) / (tSup - tInf); + distanceCoef = dInf + (t - tInf) * velocityCoef; + } + + distance = distanceCoef * mSplineDistance; + mCurrVelocity = velocityCoef * mSplineDistance / mSplineDuration * 1000.0f; + break; + } + + case BALLISTIC: { + final float t = currentTime / 1000.0f; + mCurrVelocity = mVelocity + mDeceleration * t; + distance = mVelocity * t + mDeceleration * t * t / 2.0f; + break; + } + + case CUBIC: { + final float t = (float) (currentTime) / mDuration; + final float t2 = t * t; + final float sign = Math.signum(mVelocity); + distance = sign * mOver * (3.0f * t2 - 2.0f * t * t2); + mCurrVelocity = sign * mOver * 6.0f * (-t + t2); + break; + } + } + + mCurrentPosition = mStart + (int) Math.round(distance); + + return true; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java new file mode 100644 index 000000000..560674e4f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceTextureListener.java @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +import android.graphics.SurfaceTexture; + +final class SurfaceTextureListener + extends JNIObject implements SurfaceTexture.OnFrameAvailableListener +{ + @WrapForJNI(calledFrom = "gecko") + private SurfaceTextureListener() { + } + + @Override + protected void disposeNative() { + // SurfaceTextureListener is disposed inside AndroidSurfaceTexture. + throw new IllegalStateException("unreachable code"); + } + + @WrapForJNI(stubName = "OnFrameAvailable") + private native void nativeOnFrameAvailable(); + + @Override // SurfaceTexture.OnFrameAvailableListener + public void onFrameAvailable(SurfaceTexture surfaceTexture) { + try { + nativeOnFrameAvailable(); + } catch (final NullPointerException e) { + // Ignore exceptions caused by a disposed object, i.e. + // getting a callback after this listener is no longer in use. + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ViewTransform.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ViewTransform.java new file mode 100644 index 000000000..e6685f066 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/ViewTransform.java @@ -0,0 +1,28 @@ +/* -*- 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.gfx; + +import org.mozilla.gecko.annotation.WrapForJNI; + +@WrapForJNI +public class ViewTransform { + public float x; + public float y; + public float width; + public float height; + public float scale; + public float fixedLayerMarginLeft; + public float fixedLayerMarginTop; + public float fixedLayerMarginRight; + public float fixedLayerMarginBottom; + + public ViewTransform(float inX, float inY, float inScale) { + x = inX; + y = inY; + scale = inScale; + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java new file mode 100644 index 000000000..bc9e0a143 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/ByteBufferInputStream.java @@ -0,0 +1,64 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.mozglue; + +import java.io.InputStream; +import java.nio.ByteBuffer; + +class ByteBufferInputStream extends InputStream { + + protected ByteBuffer mBuf; + // Reference to a native object holding the data backing the ByteBuffer. + private final NativeReference mNativeRef; + + protected ByteBufferInputStream(ByteBuffer buffer, NativeReference ref) { + mBuf = buffer; + mNativeRef = ref; + } + + @Override + public int available() { + return mBuf.remaining(); + } + + @Override + public void close() { + // Do nothing, we need to keep the native references around for child + // buffers. + } + + @Override + public int read() { + if (!mBuf.hasRemaining() || mNativeRef.isReleased()) { + return -1; + } + + return mBuf.get() & 0xff; // Avoid sign extension + } + + @Override + public int read(byte[] buffer, int offset, int length) { + if (!mBuf.hasRemaining() || mNativeRef.isReleased()) { + return -1; + } + + length = Math.min(length, mBuf.remaining()); + mBuf.get(buffer, offset, length); + return length; + } + + @Override + public long skip(long byteCount) { + if (byteCount < 0 || mNativeRef.isReleased()) { + return 0; + } + + byteCount = Math.min(byteCount, mBuf.remaining()); + mBuf.position(mBuf.position() + (int)byteCount); + return byteCount; + } + +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java new file mode 100644 index 000000000..b3fb24291 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/DirectBufferAllocator.java @@ -0,0 +1,52 @@ +/* -*- 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.mozglue; + +import java.nio.ByteBuffer; + +// +// We must manually allocate direct buffers in JNI to work around a bug where Honeycomb's +// ByteBuffer.allocateDirect() grossly overallocates the direct buffer size. +// https://code.google.com/p/android/issues/detail?id=16941 +// + +public final class DirectBufferAllocator { + private DirectBufferAllocator() {} + + public static ByteBuffer allocate(int size) { + if (size <= 0) { + throw new IllegalArgumentException("Invalid size " + size); + } + + ByteBuffer directBuffer = nativeAllocateDirectBuffer(size); + if (directBuffer == null) { + throw new OutOfMemoryError("allocateDirectBuffer() returned null"); + } + + if (!directBuffer.isDirect()) { + throw new AssertionError("allocateDirectBuffer() did not return a direct buffer"); + } + + return directBuffer; + } + + public static ByteBuffer free(ByteBuffer buffer) { + if (buffer == null) { + return null; + } + + if (!buffer.isDirect()) { + throw new IllegalArgumentException("buffer must be direct"); + } + + nativeFreeDirectBuffer(buffer); + return null; + } + + // These JNI methods are implemented in mozglue/android/nsGeckoUtils.cpp. + private static native ByteBuffer nativeAllocateDirectBuffer(long size); + private static native void nativeFreeDirectBuffer(ByteBuffer buf); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java new file mode 100644 index 000000000..0bef2435b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java @@ -0,0 +1,549 @@ +/* -*- 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.mozglue; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.text.NumberFormat; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import android.content.Context; +import android.os.Build; +import android.os.Environment; +import android.util.Log; + +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; + +public final class GeckoLoader { + private static final String LOGTAG = "GeckoLoader"; + + private static volatile SafeIntent sIntent; + private static File sCacheFile; + private static File sGREDir; + + /* Synchronized on GeckoLoader.class. */ + private static boolean sSQLiteLibsLoaded; + private static boolean sNSSLibsLoaded; + private static boolean sMozGlueLoaded; + + private GeckoLoader() { + // prevent instantiation + } + + public static File getCacheDir(Context context) { + if (sCacheFile == null) { + sCacheFile = context.getCacheDir(); + } + return sCacheFile; + } + + public static File getGREDir(Context context) { + if (sGREDir == null) { + sGREDir = new File(context.getApplicationInfo().dataDir); + } + return sGREDir; + } + + private static void setupPluginEnvironment(Context context, String[] pluginDirs) { + // setup plugin path directories + try { + // Check to see if plugins were blocked. + if (pluginDirs == null) { + putenv("MOZ_PLUGINS_BLOCKED=1"); + putenv("MOZ_PLUGIN_PATH="); + return; + } + + StringBuilder pluginSearchPath = new StringBuilder(); + for (int i = 0; i < pluginDirs.length; i++) { + pluginSearchPath.append(pluginDirs[i]); + pluginSearchPath.append(":"); + } + putenv("MOZ_PLUGIN_PATH=" + pluginSearchPath); + + File pluginDataDir = context.getDir("plugins", 0); + putenv("ANDROID_PLUGIN_DATADIR=" + pluginDataDir.getPath()); + + File pluginPrivateDataDir = context.getDir("plugins_private", 0); + putenv("ANDROID_PLUGIN_DATADIR_PRIVATE=" + pluginPrivateDataDir.getPath()); + + } catch (Exception ex) { + Log.w(LOGTAG, "Caught exception getting plugin dirs.", ex); + } + } + + private static void setupDownloadEnvironment(final Context context) { + try { + File downloadDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); + File updatesDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS); + if (downloadDir == null) { + downloadDir = new File(Environment.getExternalStorageDirectory().getPath(), "download"); + } + if (updatesDir == null) { + updatesDir = downloadDir; + } + putenv("DOWNLOADS_DIRECTORY=" + downloadDir.getPath()); + putenv("UPDATES_DIRECTORY=" + updatesDir.getPath()); + } catch (Exception e) { + Log.w(LOGTAG, "No download directory found.", e); + } + } + + private static void delTree(File file) { + if (file.isDirectory()) { + File children[] = file.listFiles(); + for (File child : children) { + delTree(child); + } + } + file.delete(); + } + + private static File getTmpDir(Context context) { + File tmpDir = context.getDir("tmpdir", Context.MODE_PRIVATE); + // check if the old tmp dir is there + File oldDir = new File(tmpDir.getParentFile(), "app_tmp"); + if (oldDir.exists()) { + delTree(oldDir); + } + return tmpDir; + } + + public static void setLastIntent(SafeIntent intent) { + sIntent = intent; + } + + public static void setupGeckoEnvironment(Context context, String[] pluginDirs, String profilePath) { + // if we have an intent (we're being launched by an activity) + // read in any environmental variables from it here + final SafeIntent intent = sIntent; + if (intent != null) { + String env = intent.getStringExtra("env0"); + Log.d(LOGTAG, "Gecko environment env0: " + env); + for (int c = 1; env != null; c++) { + putenv(env); + env = intent.getStringExtra("env" + c); + Log.d(LOGTAG, "env" + c + ": " + env); + } + } + + putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName()); + + setupPluginEnvironment(context, pluginDirs); + setupDownloadEnvironment(context); + + // profile home path + putenv("HOME=" + profilePath); + + // setup the tmp path + File f = getTmpDir(context); + if (!f.exists()) { + f.mkdirs(); + } + putenv("TMPDIR=" + f.getPath()); + + // setup the downloads path + f = Environment.getDownloadCacheDirectory(); + putenv("EXTERNAL_STORAGE=" + f.getPath()); + + // setup the app-specific cache path + f = context.getCacheDir(); + putenv("CACHE_DIRECTORY=" + f.getPath()); + + if (AppConstants.Versions.feature17Plus) { + android.os.UserManager um = (android.os.UserManager)context.getSystemService(Context.USER_SERVICE); + if (um != null) { + putenv("MOZ_ANDROID_USER_SERIAL_NUMBER=" + um.getSerialNumberForUser(android.os.Process.myUserHandle())); + } else { + Log.d(LOGTAG, "Unable to obtain user manager service on a device with SDK version " + Build.VERSION.SDK_INT); + } + } + setupLocaleEnvironment(); + + // We don't need this any more. + sIntent = null; + } + + private static void loadLibsSetupLocked(Context context) { + // The package data lib directory isn't placed in ld.so's + // search path, so we have to manually load libraries that + // libxul will depend on. Not ideal. + + File cacheFile = getCacheDir(context); + putenv("GRE_HOME=" + getGREDir(context).getPath()); + + // setup the libs cache + String linkerCache = System.getenv("MOZ_LINKER_CACHE"); + if (linkerCache == null) { + linkerCache = cacheFile.getPath(); + putenv("MOZ_LINKER_CACHE=" + linkerCache); + } + + // Disable on-demand decompression of the linker on devices where it + // is known to cause crashes. + String forced_ondemand = System.getenv("MOZ_LINKER_ONDEMAND"); + if (forced_ondemand == null) { + if ("HTC".equals(android.os.Build.MANUFACTURER) && + "HTC Vision".equals(android.os.Build.MODEL)) { + putenv("MOZ_LINKER_ONDEMAND=0"); + } + } + + putenv("MOZ_LINKER_EXTRACT=1"); + } + + @RobocopTarget + public synchronized static void loadSQLiteLibs(final Context context, final String apkName) { + if (sSQLiteLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadSQLiteLibsNative(apkName); + sSQLiteLibsLoaded = true; + } + + public synchronized static void loadNSSLibs(final Context context, final String apkName) { + if (sNSSLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadNSSLibsNative(apkName); + sNSSLibsLoaded = true; + } + + @SuppressWarnings("deprecation") + private static final String getCPUABI() { + return android.os.Build.CPU_ABI; + } + + /** + * Copy a library out of our APK. + * + * @param context a Context. + * @param lib the name of the library; e.g., "mozglue". + * @param outDir the output directory for the .so. No trailing slash. + * @return true on success, false on failure. + */ + private static boolean extractLibrary(final Context context, final String lib, final String outDir) { + final String apkPath = context.getApplicationInfo().sourceDir; + + // Sanity check. + if (!apkPath.endsWith(".apk")) { + Log.w(LOGTAG, "sourceDir is not an APK."); + return false; + } + + // Try to extract the named library from the APK. + File outDirFile = new File(outDir); + if (!outDirFile.isDirectory()) { + if (!outDirFile.mkdirs()) { + Log.e(LOGTAG, "Couldn't create " + outDir); + return false; + } + } + + if (AppConstants.Versions.feature21Plus) { + String[] abis = Build.SUPPORTED_ABIS; + for (String abi : abis) { + if (tryLoadWithABI(lib, outDir, apkPath, abi)) { + return true; + } + } + return false; + } else { + final String abi = getCPUABI(); + return tryLoadWithABI(lib, outDir, apkPath, abi); + } + } + + private static boolean tryLoadWithABI(String lib, String outDir, String apkPath, String abi) { + try { + final ZipFile zipFile = new ZipFile(new File(apkPath)); + try { + final String libPath = "lib/" + abi + "/lib" + lib + ".so"; + final ZipEntry entry = zipFile.getEntry(libPath); + if (entry == null) { + Log.w(LOGTAG, libPath + " not found in APK " + apkPath); + return false; + } + + final InputStream in = zipFile.getInputStream(entry); + try { + final String outPath = outDir + "/lib" + lib + ".so"; + final FileOutputStream out = new FileOutputStream(outPath); + final byte[] bytes = new byte[1024]; + int read; + + Log.d(LOGTAG, "Copying " + libPath + " to " + outPath); + boolean failed = false; + try { + while ((read = in.read(bytes, 0, 1024)) != -1) { + out.write(bytes, 0, read); + } + } catch (Exception e) { + Log.w(LOGTAG, "Failing library copy.", e); + failed = true; + } finally { + out.close(); + } + + if (failed) { + // Delete the partial copy so we don't fail to load it. + // Don't bother to check the return value -- there's nothing + // we can do about a failure. + new File(outPath).delete(); + } else { + // Mark the file as executable. This doesn't seem to be + // necessary for the loader, but it's the normal state of + // affairs. + Log.d(LOGTAG, "Marking " + outPath + " as executable."); + new File(outPath).setExecutable(true); + } + + return !failed; + } finally { + in.close(); + } + } finally { + zipFile.close(); + } + } catch (Exception e) { + Log.e(LOGTAG, "Failed to extract lib from APK.", e); + return false; + } + } + + private static String getLoadDiagnostics(final Context context, final String lib) { + final String androidPackageName = context.getPackageName(); + + final StringBuilder message = new StringBuilder("LOAD "); + message.append(lib); + + // These might differ. If so, we know why the library won't load! + message.append(": ABI: " + AppConstants.MOZ_APP_ABI + ", " + getCPUABI()); + message.append(": Data: " + context.getApplicationInfo().dataDir); + try { + final boolean appLibExists = new File("/data/app-lib/" + androidPackageName + "/lib" + lib + ".so").exists(); + final boolean dataDataExists = new File("/data/data/" + androidPackageName + "/lib/lib" + lib + ".so").exists(); + message.append(", ax=" + appLibExists); + message.append(", ddx=" + dataDataExists); + } catch (Throwable e) { + message.append(": ax/ddx fail, "); + } + + try { + final String dashOne = "/data/data/" + androidPackageName + "-1"; + final String dashTwo = "/data/data/" + androidPackageName + "-2"; + final boolean dashOneExists = new File(dashOne).exists(); + final boolean dashTwoExists = new File(dashTwo).exists(); + message.append(", -1x=" + dashOneExists); + message.append(", -2x=" + dashTwoExists); + } catch (Throwable e) { + message.append(", dash fail, "); + } + + try { + if (Build.VERSION.SDK_INT >= 9) { + final String nativeLibPath = context.getApplicationInfo().nativeLibraryDir; + final boolean nativeLibDirExists = new File(nativeLibPath).exists(); + final boolean nativeLibLibExists = new File(nativeLibPath + "/lib" + lib + ".so").exists(); + + message.append(", nativeLib: " + nativeLibPath); + message.append(", dirx=" + nativeLibDirExists); + message.append(", libx=" + nativeLibLibExists); + } else { + message.append(", <pre-9>"); + } + } catch (Throwable e) { + message.append(", nativeLib fail."); + } + + return message.toString(); + } + + private static final boolean attemptLoad(final String path) { + try { + System.load(path); + return true; + } catch (Throwable e) { + Log.wtf(LOGTAG, "Couldn't load " + path + ": " + e); + } + + return false; + } + + /** + * The first two attempts at loading a library: directly, and + * then using the app library path. + * + * Returns null or the cause exception. + */ + private static final Throwable doLoadLibraryExpected(final Context context, final String lib) { + try { + // Attempt 1: the way that should work. + System.loadLibrary(lib); + return null; + } catch (Throwable e) { + Log.wtf(LOGTAG, "Couldn't load " + lib + ". Trying native library dir."); + + if (Build.VERSION.SDK_INT < 9) { + // We can't use nativeLibraryDir. + return e; + } + + // Attempt 2: use nativeLibraryDir, which should also work. + final String libDir = context.getApplicationInfo().nativeLibraryDir; + final String libPath = libDir + "/lib" + lib + ".so"; + + // Does it even exist? + if (new File(libPath).exists()) { + if (attemptLoad(libPath)) { + // Success! + return null; + } + Log.wtf(LOGTAG, "Library exists but couldn't load!"); + } else { + Log.wtf(LOGTAG, "Library doesn't exist when it should."); + } + + // We failed. Return the original cause. + return e; + } + } + + public static void doLoadLibrary(final Context context, final String lib) { + final Throwable e = doLoadLibraryExpected(context, lib); + if (e == null) { + // Success. + return; + } + + // If we're in a mismatched UID state (Bug 1042935 Comment 16) there's really + // nothing we can do. + if (Build.VERSION.SDK_INT >= 9) { + final String nativeLibPath = context.getApplicationInfo().nativeLibraryDir; + if (nativeLibPath.contains("mismatched_uid")) { + throw new RuntimeException("Fatal: mismatched UID: cannot load."); + } + } + + // Attempt 3: try finding the path the pseudo-supported way using .dataDir. + final String dataLibPath = context.getApplicationInfo().dataDir + "/lib/lib" + lib + ".so"; + if (attemptLoad(dataLibPath)) { + return; + } + + // Attempt 4: use /data/app-lib directly. This is a last-ditch effort. + final String androidPackageName = context.getPackageName(); + if (attemptLoad("/data/app-lib/" + androidPackageName + "/lib" + lib + ".so")) { + return; + } + + // Attempt 5: even more optimistic. + if (attemptLoad("/data/data/" + androidPackageName + "/lib/lib" + lib + ".so")) { + return; + } + + // Look in our files directory, copying from the APK first if necessary. + final String filesLibDir = context.getFilesDir() + "/lib"; + final String filesLibPath = filesLibDir + "/lib" + lib + ".so"; + if (new File(filesLibPath).exists()) { + if (attemptLoad(filesLibPath)) { + return; + } + } else { + // Try copying. + if (extractLibrary(context, lib, filesLibDir)) { + // Let's try it! + if (attemptLoad(filesLibPath)) { + return; + } + } + } + + // Give up loudly, leaking information to debug the failure. + final String message = getLoadDiagnostics(context, lib); + Log.e(LOGTAG, "Load diagnostics: " + message); + + // Throw the descriptive message, using the original library load + // failure as the cause. + throw new RuntimeException(message, e); + } + + public synchronized static void loadMozGlue(final Context context) { + if (sMozGlueLoaded) { + return; + } + + doLoadLibrary(context, "mozglue"); + sMozGlueLoaded = true; + } + + public synchronized static void loadGeckoLibs(final Context context, final String apkName) { + loadLibsSetupLocked(context); + loadGeckoLibsNative(apkName); + } + + public synchronized static void extractGeckoLibs(final Context context, final String apkName) { + loadLibsSetupLocked(context); + try { + extractGeckoLibsNative(apkName); + } catch (Exception e) { + Log.e(LOGTAG, "Failing library extraction.", e); + } + } + + private static void setupLocaleEnvironment() { + putenv("LANG=" + Locale.getDefault().toString()); + NumberFormat nf = NumberFormat.getInstance(); + if (nf instanceof DecimalFormat) { + DecimalFormat df = (DecimalFormat)nf; + DecimalFormatSymbols dfs = df.getDecimalFormatSymbols(); + + putenv("LOCALE_DECIMAL_POINT=" + dfs.getDecimalSeparator()); + putenv("LOCALE_THOUSANDS_SEP=" + dfs.getGroupingSeparator()); + putenv("LOCALE_GROUPING=" + (char)df.getGroupingSize()); + } + } + + @SuppressWarnings("serial") + public static class AbortException extends Exception { + public AbortException(String msg) { + super(msg); + } + } + + @JNITarget + public static void abort(final String msg) { + final Thread thread = Thread.currentThread(); + final Thread.UncaughtExceptionHandler uncaughtHandler = + thread.getUncaughtExceptionHandler(); + if (uncaughtHandler != null) { + uncaughtHandler.uncaughtException(thread, new AbortException(msg)); + } + } + + // These methods are implemented in mozglue/android/nsGeckoUtils.cpp + private static native void putenv(String map); + + // These methods are implemented in mozglue/android/APKOpen.cpp + public static native void nativeRun(String args); + private static native void loadGeckoLibsNative(String apkName); + private static native void loadSQLiteLibsNative(String apkName); + private static native void loadNSSLibsNative(String apkName); + private static native void extractGeckoLibsNative(String apkName); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java new file mode 100644 index 000000000..a3a127a1a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java @@ -0,0 +1,11 @@ +package org.mozilla.gecko.mozglue; + +// Class that all classes with native methods extend from. +public abstract class JNIObject +{ + // Pointer to a WeakPtr object that refers to the native object. + private long mHandle; + + // Dispose of any reference to a native object. + protected abstract void disposeNative(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java new file mode 100644 index 000000000..9d897d384 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java @@ -0,0 +1,13 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.mozglue; + +public interface NativeReference +{ + public void release(); + + public boolean isReleased(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeZip.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeZip.java new file mode 100644 index 000000000..11241c575 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeZip.java @@ -0,0 +1,84 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.mozglue; + +import android.support.annotation.Keep; +import org.mozilla.gecko.annotation.JNITarget; + +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.zip.Inflater; +import java.util.zip.InflaterInputStream; + +public class NativeZip implements NativeReference { + private static final int DEFLATE = 8; + private static final int STORE = 0; + + private volatile long mObj; + @Keep + private InputStream mInput; + + public NativeZip(String path) { + mObj = getZip(path); + } + + public NativeZip(InputStream input) { + if (!(input instanceof ByteBufferInputStream)) { + throw new IllegalArgumentException("Got " + input.getClass() + + ", but expected ByteBufferInputStream!"); + } + ByteBufferInputStream bbinput = (ByteBufferInputStream)input; + mObj = getZipFromByteBuffer(bbinput.mBuf); + mInput = input; + } + + @Override + protected void finalize() { + release(); + } + + @Override + public void release() { + if (mObj != 0) { + _release(mObj); + mObj = 0; + } + mInput = null; + } + + @Override + public boolean isReleased() { + return (mObj == 0); + } + + public InputStream getInputStream(String path) { + if (isReleased()) { + throw new IllegalStateException("Can't get path \"" + path + + "\" because NativeZip is closed!"); + } + return _getInputStream(mObj, path); + } + + private static native long getZip(String path); + private static native long getZipFromByteBuffer(ByteBuffer buffer); + private static native void _release(long obj); + private native InputStream _getInputStream(long obj, String path); + + @JNITarget + private InputStream createInputStream(ByteBuffer buffer, int compression) { + if (compression != STORE && compression != DEFLATE) { + throw new IllegalArgumentException("Unexpected compression: " + compression); + } + + InputStream input = new ByteBufferInputStream(buffer, this); + if (compression == DEFLATE) { + Inflater inflater = new Inflater(true); + input = new InflaterInputStream(input, inflater); + } + + return input; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java new file mode 100644 index 000000000..6942962fe --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SafeIntent.java @@ -0,0 +1,134 @@ +/* + * 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/. + */ + +// This should be in util/, but is here because of build dependency issues. +package org.mozilla.gecko.mozglue; + +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; + +import java.util.ArrayList; + +/** + * External applications can pass values into Intents that can cause us to crash: in defense, + * we wrap {@link Intent} and catch the exceptions they may force us to throw. See bug 1090385 + * for more. + */ +public class SafeIntent { + private static final String LOGTAG = "Gecko" + SafeIntent.class.getSimpleName(); + + private final Intent intent; + + public SafeIntent(final Intent intent) { + this.intent = intent; + } + + public boolean hasExtra(String name) { + try { + return intent.hasExtra(name); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't determine if intent had an extra: OOM. Malformed?"); + return false; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't determine if intent had an extra.", e); + return false; + } + } + + public boolean getBooleanExtra(final String name, final boolean defaultValue) { + try { + return intent.getBooleanExtra(name, defaultValue); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?"); + return defaultValue; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent extras.", e); + return defaultValue; + } + } + + public int getIntExtra(final String name, final int defaultValue) { + try { + return intent.getIntExtra(name, defaultValue); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?"); + return defaultValue; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent extras.", e); + return defaultValue; + } + } + + public String getStringExtra(final String name) { + try { + return intent.getStringExtra(name); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?"); + return null; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent extras.", e); + return null; + } + } + + public Bundle getBundleExtra(final String name) { + try { + return intent.getBundleExtra(name); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent extras: OOM. Malformed?"); + return null; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent extras.", e); + return null; + } + } + + public String getAction() { + return intent.getAction(); + } + + public String getDataString() { + try { + return intent.getDataString(); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent data string: OOM. Malformed?"); + return null; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent data string.", e); + return null; + } + } + + public ArrayList<String> getStringArrayListExtra(final String name) { + try { + return intent.getStringArrayListExtra(name); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent data string: OOM. Malformed?"); + return null; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent data string.", e); + return null; + } + } + + public Uri getData() { + try { + return intent.getData(); + } catch (OutOfMemoryError e) { + Log.w(LOGTAG, "Couldn't get intent data: OOM. Malformed?"); + return null; + } catch (RuntimeException e) { + Log.w(LOGTAG, "Couldn't get intent data.", e); + return null; + } + } + + public Intent getUnsafe() { + return intent; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionBlock.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionBlock.java new file mode 100644 index 000000000..a4d72f258 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionBlock.java @@ -0,0 +1,133 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.permissions; + +import org.mozilla.gecko.util.ThreadUtils; + +import android.app.Activity; +import android.content.Context; +import android.support.annotation.NonNull; + +/** + * Helper class to run code blocks depending on whether a user has granted or denied certain runtime permissions. + */ +public class PermissionBlock { + private final PermissionsHelper helper; + + private Context context; + private String[] permissions; + private boolean onUIThread; + private Runnable onPermissionsGranted; + private Runnable onPermissionsDenied; + private boolean doNotPrompt; + + /* package-private */ PermissionBlock(Context context, PermissionsHelper helper) { + this.context = context; + this.helper = helper; + } + + /** + * Determine whether the app has been granted the specified permissions. + */ + public PermissionBlock withPermissions(@NonNull String... permissions) { + this.permissions = permissions; + return this; + } + + /** + * Execute all callbacks on the UI thread. + */ + public PermissionBlock onUIThread() { + this.onUIThread = true; + return this; + } + + /** + * Do not prompt the user to accept the permission if it has not been granted yet. + */ + public PermissionBlock doNotPrompt() { + doNotPrompt = true; + return this; + } + + /** + * If the condition is true then do not prompt the user to accept the permission if it has not + * been granted yet. + */ + public PermissionBlock doNotPromptIf(boolean condition) { + if (condition) { + doNotPrompt(); + } + + return this; + } + + /** + * Execute this permission block. Calling this method will prompt the user if needed. + */ + public void run() { + run(null); + } + + /** + * Execute the specified runnable if the app has been granted all permissions. Calling this method will prompt the + * user if needed. + */ + public void run(Runnable onPermissionsGranted) { + if (!doNotPrompt && !(context instanceof Activity)) { + throw new IllegalStateException("You need to either specify doNotPrompt() or pass in an Activity context"); + } + + this.onPermissionsGranted = onPermissionsGranted; + + if (hasPermissions(context)) { + onPermissionsGranted(); + } else if (doNotPrompt) { + onPermissionsDenied(); + } else { + Permissions.prompt((Activity) context, this); + } + + // This reference is no longer needed. Let's clear it now to avoid memory leaks. + context = null; + } + + /** + * Execute this fallback if at least one permission has not been granted. + */ + public PermissionBlock andFallback(@NonNull Runnable onPermissionsDenied) { + this.onPermissionsDenied = onPermissionsDenied; + return this; + } + + /* package-private */ void onPermissionsGranted() { + executeRunnable(onPermissionsGranted); + } + + /* package-private */ void onPermissionsDenied() { + executeRunnable(onPermissionsDenied); + } + + private void executeRunnable(Runnable runnable) { + if (runnable == null) { + return; + } + + if (onUIThread) { + ThreadUtils.postToUiThread(runnable); + } else { + runnable.run(); + } + } + + /* package-private */ String[] getPermissions() { + return permissions; + } + + /* packacge-private */ boolean hasPermissions(Context context) { + return helper.hasPermissions(context, permissions); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/Permissions.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/Permissions.java new file mode 100644 index 000000000..c1b38f61c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/Permissions.java @@ -0,0 +1,210 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.permissions; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.support.annotation.NonNull; + +import org.mozilla.gecko.util.ThreadUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.Callable; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; + +/** + * Convenience class for checking and prompting for runtime permissions. + * + * Example: + * + * Permissions.from(activity) + * .withPermissions(Manifest.permission.WRITE_EXTERNAL_STORAGE) + * .onUiThread() + * .andFallback(onPermissionDenied()) + * .run(onPermissionGranted()) + * + * This example will run the runnable returned by onPermissionGranted() if the WRITE_EXTERNAL_STORAGE permission is + * already granted. Otherwise it will prompt the user and run the runnable returned by onPermissionGranted() or + * onPermissionDenied() depending on whether the user accepted or not. If onUiThread() is specified then all callbacks + * will be run on the UI thread. + */ +public class Permissions { + private static final Queue<PermissionBlock> waiting = new LinkedList<>(); + private static final Queue<PermissionBlock> prompt = new LinkedList<>(); + + private static PermissionsHelper permissionHelper = new PermissionsHelper(); + + /** + * Entry point for checking (and optionally prompting for) runtime permissions. + * + * Note: The provided context needs to be an Activity context in order to prompt. Use doNotPrompt() + * for all other contexts. + */ + public static PermissionBlock from(@NonNull Context context) { + return new PermissionBlock(context, permissionHelper); + } + + /** + * This method will block until the specified permissions have been granted or denied by the user. + * If needed the user will be prompted. + * + * @return true if all of the permissions have been granted. False if any of the permissions have been denied. + */ + public static boolean waitFor(@NonNull Activity activity, String... permissions) { + ThreadUtils.assertNotOnUiThread(); // We do not want to block the UI thread. + + // This task will block until all of the permissions have been granted + final FutureTask<Boolean> blockingTask = new FutureTask<>(new Callable<Boolean>() { + @Override + public Boolean call() throws Exception { + return true; + } + }); + + // This runnable will cancel the task if any of the permissions have been denied + Runnable cancelBlockingTask = new Runnable() { + @Override + public void run() { + blockingTask.cancel(true); + } + }; + + Permissions.from(activity) + .withPermissions(permissions) + .andFallback(cancelBlockingTask) + .run(blockingTask); + + try { + return blockingTask.get(); + } catch (InterruptedException | ExecutionException | CancellationException e) { + return false; + } + } + + /** + * Determine whether you have been granted particular permissions. + */ + public static boolean has(Context context, String... permissions) { + return permissionHelper.hasPermissions(context, permissions); + } + + /* package-private */ static void setPermissionHelper(PermissionsHelper permissionHelper) { + Permissions.permissionHelper = permissionHelper; + } + + /** + * Callback for Activity.onRequestPermissionsResult(). All activities that prompt for permissions using this class + * should implement onRequestPermissionsResult() and call this method. + */ + public static synchronized void onRequestPermissionsResult(@NonNull Activity activity, @NonNull String[] permissions, @NonNull int[] grantResults) { + processGrantResults(permissions, grantResults); + + processQueue(activity, permissions, grantResults); + } + + /* package-private */ static synchronized void prompt(Activity activity, PermissionBlock block) { + if (prompt.isEmpty()) { + prompt.add(block); + showPrompt(activity); + } else { + waiting.add(block); + } + } + + private static synchronized void processGrantResults(@NonNull String[] permissions, @NonNull int[] grantResults) { + final HashSet<String> grantedPermissions = collectGrantedPermissions(permissions, grantResults); + + while (!prompt.isEmpty()) { + final PermissionBlock block = prompt.poll(); + + if (allPermissionsGranted(block, grantedPermissions)) { + block.onPermissionsGranted(); + } else { + block.onPermissionsDenied(); + } + } + } + + private static synchronized void processQueue(Activity activity, String[] permissions, int[] grantResults) { + final HashSet<String> deniedPermissions = collectDeniedPermissions(permissions, grantResults); + + while (!waiting.isEmpty()) { + final PermissionBlock block = waiting.poll(); + + if (block.hasPermissions(activity)) { + block.onPermissionsGranted(); + } else { + if (atLeastOnePermissionDenied(block, deniedPermissions)) { + // We just prompted the user and one of the permissions of this block has been denied: + // There's no reason to instantly prompt again; Just reject without prompting. + block.onPermissionsDenied(); + } else { + prompt.add(block); + } + } + } + + if (!prompt.isEmpty()) { + showPrompt(activity); + } + } + + private static synchronized void showPrompt(Activity activity) { + HashSet<String> permissions = new HashSet<>(); + + for (PermissionBlock block : prompt) { + Collections.addAll(permissions, block.getPermissions()); + } + + permissionHelper.prompt(activity, permissions.toArray(new String[permissions.size()])); + } + + private static HashSet<String> collectGrantedPermissions(@NonNull String[] permissions, @NonNull int[] grantResults) { + return filterPermissionsByResult(permissions, grantResults, PackageManager.PERMISSION_GRANTED); + } + + private static HashSet<String> collectDeniedPermissions(@NonNull String[] permissions, @NonNull int[] grantResults) { + return filterPermissionsByResult(permissions, grantResults, PackageManager.PERMISSION_DENIED); + } + + private static HashSet<String> filterPermissionsByResult(@NonNull String[] permissions, @NonNull int[] grantResults, int result) { + HashSet<String> grantedPermissions = new HashSet<>(permissions.length); + for (int i = 0; i < permissions.length; i++) { + if (grantResults[i] == result) { + grantedPermissions.add(permissions[i]); + } + } + return grantedPermissions; + } + + private static boolean allPermissionsGranted(PermissionBlock block, HashSet<String> grantedPermissions) { + for (String permission : block.getPermissions()) { + if (!grantedPermissions.contains(permission)) { + return false; + } + } + + return true; + } + + private static boolean atLeastOnePermissionDenied(PermissionBlock block, HashSet<String> deniedPermissions) { + for (String permission : block.getPermissions()) { + if (deniedPermissions.contains(permission)) { + return true; + } + } + + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionsHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionsHelper.java new file mode 100644 index 000000000..945a81f43 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/permissions/PermissionsHelper.java @@ -0,0 +1,32 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.permissions; + +import android.app.Activity; +import android.content.Context; +import android.content.pm.PackageManager; +import android.support.v4.app.ActivityCompat; +import android.support.v4.content.ContextCompat; + +/* package-private */ class PermissionsHelper { + private static final int PERMISSIONS_REQUEST_CODE = 212; + + public boolean hasPermissions(Context context, String... permissions) { + for (String permission : permissions) { + final int permissionCheck = ContextCompat.checkSelfPermission(context, permission); + + if (permissionCheck != PackageManager.PERMISSION_GRANTED) { + return false; + } + } + + return true; + } + + public void prompt(Activity activity, String[] permissions) { + ActivityCompat.requestPermissions(activity, permissions, PERMISSIONS_REQUEST_CODE); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/ByteBufferInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/ByteBufferInputStream.java new file mode 100644 index 000000000..f6b16619f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/ByteBufferInputStream.java @@ -0,0 +1,38 @@ +/* -*- 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.sqlite; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; + +/* + * Helper class to make the ByteBuffers returned by SQLite BLOB + * easier to use. + */ +public class ByteBufferInputStream extends InputStream { + private final ByteBuffer mByteBuffer; + + public ByteBufferInputStream(ByteBuffer aByteBuffer) { + mByteBuffer = aByteBuffer; + } + + @Override + public synchronized int read() throws IOException { + if (!mByteBuffer.hasRemaining()) { + return -1; + } + return mByteBuffer.get(); + } + + @Override + public synchronized int read(byte[] aBytes, int aOffset, int aLen) + throws IOException { + int toRead = Math.min(aLen, mByteBuffer.remaining()); + mByteBuffer.get(aBytes, aOffset, toRead); + return toRead; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/MatrixBlobCursor.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/MatrixBlobCursor.java new file mode 100644 index 000000000..3e2023c86 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/MatrixBlobCursor.java @@ -0,0 +1,366 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.mozilla.gecko.sqlite; + +import java.nio.ByteBuffer; +import java.util.ArrayList; + +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.AppConstants; + +import android.database.AbstractCursor; +import android.database.CursorIndexOutOfBoundsException; +import android.util.Log; + +/** + * A mutable cursor implementation backed by an array of {@code Object}s. Use + * {@link #newRow()} to add rows. Automatically expands internal capacity + * as needed. + * + * This class provides one missing feature from Android's MatrixCursor: + * the implementation of getBlob that was inadvertently omitted from API 9 (and + * perhaps later; it's present in 14). + * + * MatrixCursor is all private, so we entirely duplicate it here. + */ +public class MatrixBlobCursor extends AbstractCursor { + private static final String LOGTAG = "GeckoMatrixCursor"; + + private final String[] columnNames; + private final int columnCount; + + private int rowCount; + private Throwable allocationStack; + + Object[] data; + + /** + * Constructs a new cursor with the given initial capacity. + * + * @param columnNames names of the columns, the ordering of which + * determines column ordering elsewhere in this cursor + * @param initialCapacity in rows + */ + @JNITarget + public MatrixBlobCursor(String[] columnNames, int initialCapacity) { + this.columnNames = columnNames; + this.columnCount = columnNames.length; + + if (initialCapacity < 1) { + initialCapacity = 1; + } + + this.data = new Object[columnCount * initialCapacity]; + if (AppConstants.DEBUG_BUILD) { + this.allocationStack = new Throwable("allocationStack"); + } + } + + /** + * Constructs a new cursor. + * + * @param columnNames names of the columns, the ordering of which + * determines column ordering elsewhere in this cursor + */ + @JNITarget + public MatrixBlobCursor(String[] columnNames) { + this(columnNames, 16); + } + + /** + * Closes the Cursor, releasing all of its resources. + */ + public void close() { + this.allocationStack = null; + this.data = null; + super.close(); + } + + /** + * Gets value at the given column for the current row. + */ + protected Object get(int column) { + if (column < 0 || column >= columnCount) { + throw new CursorIndexOutOfBoundsException("Requested column: " + + column + ", # of columns: " + columnCount); + } + if (mPos < 0) { + throw new CursorIndexOutOfBoundsException("Before first row."); + } + if (mPos >= rowCount) { + throw new CursorIndexOutOfBoundsException("After last row."); + } + return data[mPos * columnCount + column]; + } + + /** + * Adds a new row to the end and returns a builder for that row. Not safe + * for concurrent use. + * + * @return builder which can be used to set the column values for the new + * row + */ + public RowBuilder newRow() { + rowCount++; + int endIndex = rowCount * columnCount; + ensureCapacity(endIndex); + int start = endIndex - columnCount; + return new RowBuilder(start, endIndex); + } + + /** + * Adds a new row to the end with the given column values. Not safe + * for concurrent use. + * + * @throws IllegalArgumentException if {@code columnValues.length != + * columnNames.length} + * @param columnValues in the same order as the the column names specified + * at cursor construction time + */ + @JNITarget + public void addRow(Object[] columnValues) { + if (columnValues.length != columnCount) { + throw new IllegalArgumentException("columnNames.length = " + + columnCount + ", columnValues.length = " + + columnValues.length); + } + + int start = rowCount++ * columnCount; + ensureCapacity(start + columnCount); + System.arraycopy(columnValues, 0, data, start, columnCount); + } + + /** + * Adds a new row to the end with the given column values. Not safe + * for concurrent use. + * + * @throws IllegalArgumentException if {@code columnValues.size() != + * columnNames.length} + * @param columnValues in the same order as the the column names specified + * at cursor construction time + */ + @JNITarget + public void addRow(Iterable<?> columnValues) { + final int start = rowCount * columnCount; + + if (columnValues instanceof ArrayList<?>) { + addRow((ArrayList<?>) columnValues, start); + return; + } + + final int end = start + columnCount; + int current = start; + + ensureCapacity(end); + final Object[] localData = data; + for (Object columnValue : columnValues) { + if (current == end) { + // TODO: null out row? + throw new IllegalArgumentException( + "columnValues.size() > columnNames.length"); + } + localData[current++] = columnValue; + } + + if (current != end) { + // TODO: null out row? + throw new IllegalArgumentException( + "columnValues.size() < columnNames.length"); + } + + // Increase row count here in case we encounter an exception. + rowCount++; + } + + /** Optimization for {@link ArrayList}. */ + @JNITarget + private void addRow(ArrayList<?> columnValues, int start) { + final int size = columnValues.size(); + if (size != columnCount) { + throw new IllegalArgumentException("columnNames.length = " + + columnCount + ", columnValues.size() = " + size); + } + + final int end = start + columnCount; + ensureCapacity(end); + + // Take a reference just in case someone calls ensureCapacity + // and `data` gets replaced by a new array! + final Object[] localData = data; + for (int i = 0; i < size; i++) { + localData[start + i] = columnValues.get(i); + } + + rowCount++; + } + + /** + * Ensures that this cursor has enough capacity. If it needs to allocate + * a new array, the existing capacity will be at least doubled. + */ + private void ensureCapacity(final int size) { + if (size <= data.length) { + return; + } + + final Object[] oldData = this.data; + this.data = new Object[Math.max(size, data.length * 2)]; + System.arraycopy(oldData, 0, this.data, 0, oldData.length); + } + + /** + * Builds a row, starting from the left-most column and adding one column + * value at a time. Follows the same ordering as the column names specified + * at cursor construction time. + * + * Not thread-safe. + */ + public class RowBuilder { + private int index; + private final int endIndex; + + RowBuilder(int index, int endIndex) { + this.index = index; + this.endIndex = endIndex; + } + + /** + * Sets the next column value in this row. + * + * @throws CursorIndexOutOfBoundsException if you try to add too many + * values + * @return this builder to support chaining + */ + public RowBuilder add(final Object columnValue) { + if (index == endIndex) { + throw new CursorIndexOutOfBoundsException("No more columns left."); + } + + data[index++] = columnValue; + return this; + } + } + + /** + * Not thread safe. + */ + public void set(int column, Object value) { + if (column < 0 || column >= columnCount) { + throw new CursorIndexOutOfBoundsException("Requested column: " + + column + ", # of columns: " + columnCount); + } + if (mPos < 0) { + throw new CursorIndexOutOfBoundsException("Before first row."); + } + if (mPos >= rowCount) { + throw new CursorIndexOutOfBoundsException("After last row."); + } + data[mPos * columnCount + column] = value; + } + + // AbstractCursor implementation. + @Override + public int getCount() { + return rowCount; + } + + @Override + public String[] getColumnNames() { + return columnNames; + } + + @Override + public String getString(int column) { + Object value = get(column); + if (value == null) return null; + return value.toString(); + } + + @Override + public short getShort(int column) { + final Object value = get(column); + if (value == null) return 0; + if (value instanceof Number) return ((Number) value).shortValue(); + return Short.parseShort(value.toString()); + } + + @Override + public int getInt(int column) { + Object value = get(column); + if (value == null) return 0; + if (value instanceof Number) return ((Number) value).intValue(); + return Integer.parseInt(value.toString()); + } + + @Override + public long getLong(int column) { + Object value = get(column); + if (value == null) return 0; + if (value instanceof Number) return ((Number) value).longValue(); + return Long.parseLong(value.toString()); + } + + @Override + public float getFloat(int column) { + Object value = get(column); + if (value == null) return 0.0f; + if (value instanceof Number) return ((Number) value).floatValue(); + return Float.parseFloat(value.toString()); + } + + @Override + public double getDouble(int column) { + Object value = get(column); + if (value == null) return 0.0d; + if (value instanceof Number) return ((Number) value).doubleValue(); + return Double.parseDouble(value.toString()); + } + + @Override + public byte[] getBlob(int column) { + Object value = get(column); + if (value == null) return null; + if (value instanceof byte[]) { + return (byte[]) value; + } + + if (value instanceof ByteBuffer) { + final ByteBuffer bytes = (ByteBuffer) value; + byte[] byteArray = new byte[bytes.remaining()]; + bytes.get(byteArray); + return byteArray; + } + throw new UnsupportedOperationException("BLOB Object not of known type"); + } + + @Override + public boolean isNull(int column) { + return get(column) == null; + } + + @Override + protected void finalize() { + if (AppConstants.DEBUG_BUILD) { + if (!isClosed()) { + Log.e(LOGTAG, "Cursor finalized without being closed", this.allocationStack); + } + } + + super.finalize(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridge.java new file mode 100644 index 000000000..866b9e286 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridge.java @@ -0,0 +1,387 @@ +/* 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.sqlite; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.text.TextUtils; +import android.util.Log; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Map.Entry; + +/* + * This class allows using the mozsqlite3 library included with Firefox + * to read SQLite databases, instead of the Android SQLiteDataBase API, + * which might use whatever outdated DB is present on the Android system. + */ +public class SQLiteBridge { + private static final String LOGTAG = "SQLiteBridge"; + + // Path to the database. If this database was not opened with openDatabase, we reopen it every query. + private final String mDb; + + // Pointer to the database if it was opened with openDatabase. 0 implies closed. + protected volatile long mDbPointer; + + // Values remembered after a query. + private long[] mQueryResults; + + private boolean mTransactionSuccess; + private boolean mInTransaction; + + private static final int RESULT_INSERT_ROW_ID = 0; + private static final int RESULT_ROWS_CHANGED = 1; + + // Shamelessly cribbed from db/sqlite3/src/moz.build. + private static final int DEFAULT_PAGE_SIZE_BYTES = 32768; + + // The same size we use elsewhere. + private static final int MAX_WAL_SIZE_BYTES = 524288; + + // JNI code in $(topdir)/mozglue/android/.. + private static native MatrixBlobCursor sqliteCall(String aDb, String aQuery, + String[] aParams, + long[] aUpdateResult) + throws SQLiteBridgeException; + private static native MatrixBlobCursor sqliteCallWithDb(long aDb, String aQuery, + String[] aParams, + long[] aUpdateResult) + throws SQLiteBridgeException; + private static native long openDatabase(String aDb) + throws SQLiteBridgeException; + private static native void closeDatabase(long aDb); + + // Takes the path to the database we want to access. + @RobocopTarget + public SQLiteBridge(String aDb) throws SQLiteBridgeException { + mDb = aDb; + } + + // Executes a simple line of sql. + public void execSQL(String sql) + throws SQLiteBridgeException { + Cursor cursor = internalQuery(sql, null); + cursor.close(); + } + + // Executes a simple line of sql. Allow you to bind arguments + public void execSQL(String sql, String[] bindArgs) + throws SQLiteBridgeException { + Cursor cursor = internalQuery(sql, bindArgs); + cursor.close(); + } + + // Executes a DELETE statement on the database + public int delete(String table, String whereClause, String[] whereArgs) + throws SQLiteBridgeException { + StringBuilder sb = new StringBuilder("DELETE from "); + sb.append(table); + if (whereClause != null) { + sb.append(" WHERE " + whereClause); + } + + execSQL(sb.toString(), whereArgs); + return (int)mQueryResults[RESULT_ROWS_CHANGED]; + } + + public Cursor query(String table, + String[] columns, + String selection, + String[] selectionArgs, + String groupBy, + String having, + String orderBy, + String limit) + throws SQLiteBridgeException { + StringBuilder sb = new StringBuilder("SELECT "); + if (columns != null) + sb.append(TextUtils.join(", ", columns)); + else + sb.append(" * "); + + sb.append(" FROM "); + sb.append(table); + + if (selection != null) { + sb.append(" WHERE " + selection); + } + + if (groupBy != null) { + sb.append(" GROUP BY " + groupBy); + } + + if (having != null) { + sb.append(" HAVING " + having); + } + + if (orderBy != null) { + sb.append(" ORDER BY " + orderBy); + } + + if (limit != null) { + sb.append(" " + limit); + } + + return rawQuery(sb.toString(), selectionArgs); + } + + @RobocopTarget + public Cursor rawQuery(String sql, String[] selectionArgs) + throws SQLiteBridgeException { + return internalQuery(sql, selectionArgs); + } + + public long insert(String table, String nullColumnHack, ContentValues values) + throws SQLiteBridgeException { + if (values == null) + return 0; + + ArrayList<String> valueNames = new ArrayList<String>(); + ArrayList<String> valueBinds = new ArrayList<String>(); + ArrayList<String> keyNames = new ArrayList<String>(); + + for (Entry<String, Object> value : values.valueSet()) { + keyNames.add(value.getKey()); + + Object val = value.getValue(); + if (val == null) { + valueNames.add("NULL"); + } else { + valueNames.add("?"); + valueBinds.add(val.toString()); + } + } + + StringBuilder sb = new StringBuilder("INSERT into "); + sb.append(table); + + sb.append(" ("); + sb.append(TextUtils.join(", ", keyNames)); + sb.append(")"); + + // XXX - Do we need to bind these values? + sb.append(" VALUES ("); + sb.append(TextUtils.join(", ", valueNames)); + sb.append(") "); + + String[] binds = new String[valueBinds.size()]; + valueBinds.toArray(binds); + execSQL(sb.toString(), binds); + return mQueryResults[RESULT_INSERT_ROW_ID]; + } + + public int update(String table, ContentValues values, String whereClause, String[] whereArgs) + throws SQLiteBridgeException { + if (values == null) + return 0; + + ArrayList<String> valueNames = new ArrayList<String>(); + + StringBuilder sb = new StringBuilder("UPDATE "); + sb.append(table); + sb.append(" SET "); + + boolean isFirst = true; + + for (Entry<String, Object> value : values.valueSet()) { + if (isFirst) + isFirst = false; + else + sb.append(", "); + + sb.append(value.getKey()); + + Object val = value.getValue(); + if (val == null) { + sb.append(" = NULL"); + } else { + sb.append(" = ?"); + valueNames.add(val.toString()); + } + } + + if (!TextUtils.isEmpty(whereClause)) { + sb.append(" WHERE "); + sb.append(whereClause); + valueNames.addAll(Arrays.asList(whereArgs)); + } + + String[] binds = new String[valueNames.size()]; + valueNames.toArray(binds); + + execSQL(sb.toString(), binds); + return (int)mQueryResults[RESULT_ROWS_CHANGED]; + } + + public int getVersion() + throws SQLiteBridgeException { + Cursor cursor = internalQuery("PRAGMA user_version", null); + int ret = -1; + if (cursor != null) { + cursor.moveToFirst(); + String version = cursor.getString(0); + ret = Integer.parseInt(version); + cursor.close(); + } + return ret; + } + + // Do an SQL query, substituting the parameters in the query with the passed + // parameters. The parameters are substituted in order: named parameters + // are not supported. + private Cursor internalQuery(String aQuery, String[] aParams) + throws SQLiteBridgeException { + + mQueryResults = new long[2]; + if (isOpen()) { + return sqliteCallWithDb(mDbPointer, aQuery, aParams, mQueryResults); + } + return sqliteCall(mDb, aQuery, aParams, mQueryResults); + } + + /* + * The second two parameters here are just provided for compatibility with SQLiteDatabase + * Support for them is not currently implemented. + */ + public static SQLiteBridge openDatabase(String path, SQLiteDatabase.CursorFactory factory, int flags) + throws SQLiteException { + if (factory != null) { + throw new RuntimeException("factory not supported."); + } + if (flags != 0) { + throw new RuntimeException("flags not supported."); + } + + SQLiteBridge bridge = null; + try { + bridge = new SQLiteBridge(path); + bridge.mDbPointer = SQLiteBridge.openDatabase(path); + } catch (SQLiteBridgeException ex) { + // Catch and rethrow as a SQLiteException to match SQLiteDatabase. + throw new SQLiteException(ex.getMessage()); + } + + prepareWAL(bridge); + + return bridge; + } + + public void close() { + if (isOpen()) { + closeDatabase(mDbPointer); + } + mDbPointer = 0L; + } + + public boolean isOpen() { + return mDbPointer != 0; + } + + public void beginTransaction() throws SQLiteBridgeException { + if (inTransaction()) { + throw new SQLiteBridgeException("Nested transactions are not supported"); + } + execSQL("BEGIN EXCLUSIVE"); + mTransactionSuccess = false; + mInTransaction = true; + } + + public void beginTransactionNonExclusive() throws SQLiteBridgeException { + if (inTransaction()) { + throw new SQLiteBridgeException("Nested transactions are not supported"); + } + execSQL("BEGIN IMMEDIATE"); + mTransactionSuccess = false; + mInTransaction = true; + } + + public void endTransaction() { + if (!inTransaction()) + return; + + try { + if (mTransactionSuccess) { + execSQL("COMMIT TRANSACTION"); + } else { + execSQL("ROLLBACK TRANSACTION"); + } + } catch (SQLiteBridgeException ex) { + Log.e(LOGTAG, "Error ending transaction", ex); + } + mInTransaction = false; + mTransactionSuccess = false; + } + + public void setTransactionSuccessful() throws SQLiteBridgeException { + if (!inTransaction()) { + throw new SQLiteBridgeException("setTransactionSuccessful called outside a transaction"); + } + mTransactionSuccess = true; + } + + public boolean inTransaction() { + return mInTransaction; + } + + @Override + public void finalize() { + if (isOpen()) { + Log.e(LOGTAG, "Bridge finalized without closing the database"); + close(); + } + } + + private static void prepareWAL(final SQLiteBridge bridge) { + // Prepare for WAL mode. If we can, we switch to journal_mode=WAL, then + // set the checkpoint size appropriately. If we can't, then we fall back + // to truncating and synchronous writes. + final Cursor cursor = bridge.internalQuery("PRAGMA journal_mode=WAL", null); + try { + if (cursor.moveToFirst()) { + String journalMode = cursor.getString(0); + Log.d(LOGTAG, "Journal mode: " + journalMode); + if ("wal".equals(journalMode)) { + // Success! Let's make sure we autocheckpoint at a reasonable interval. + final int pageSizeBytes = bridge.getPageSizeBytes(); + final int checkpointPageCount = MAX_WAL_SIZE_BYTES / pageSizeBytes; + bridge.execSQL("PRAGMA wal_autocheckpoint=" + checkpointPageCount); + } else { + if (!"truncate".equals(journalMode)) { + Log.w(LOGTAG, "Unable to activate WAL journal mode. Using truncate instead."); + bridge.execSQL("PRAGMA journal_mode=TRUNCATE"); + } + Log.w(LOGTAG, "Not using WAL mode: using synchronous=FULL instead."); + bridge.execSQL("PRAGMA synchronous=FULL"); + } + } + } finally { + cursor.close(); + } + } + + private int getPageSizeBytes() { + if (!isOpen()) { + throw new IllegalStateException("Database not open."); + } + + final Cursor cursor = internalQuery("PRAGMA page_size", null); + try { + if (!cursor.moveToFirst()) { + Log.w(LOGTAG, "Unable to retrieve page size."); + return DEFAULT_PAGE_SIZE_BYTES; + } + + return cursor.getInt(0); + } finally { + cursor.close(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridgeException.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridgeException.java new file mode 100644 index 000000000..c7999fc5c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/sqlite/SQLiteBridgeException.java @@ -0,0 +1,18 @@ +/* -*- 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.sqlite; + +import org.mozilla.gecko.annotation.JNITarget; + +@JNITarget +public class SQLiteBridgeException extends RuntimeException { + static final long serialVersionUID = 1L; + + public SQLiteBridgeException() {} + public SQLiteBridgeException(String msg) { + super(msg); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandler.java new file mode 100644 index 000000000..6f40ee96b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandler.java @@ -0,0 +1,11 @@ +/* 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.util; + +import android.content.Intent; + +public interface ActivityResultHandler { + void onActivityResult(int resultCode, Intent data); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandlerMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandlerMap.java new file mode 100644 index 000000000..dc1d26cec --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityResultHandlerMap.java @@ -0,0 +1,24 @@ +/* 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.util; + +import android.util.SparseArray; + +public final class ActivityResultHandlerMap { + private final SparseArray<ActivityResultHandler> mMap = new SparseArray<ActivityResultHandler>(); + private int mCounter; + + public synchronized int put(ActivityResultHandler handler) { + mMap.put(mCounter, handler); + return mCounter++; + } + + public synchronized ActivityResultHandler getAndRemove(int i) { + ActivityResultHandler handler = mMap.get(i); + mMap.delete(i); + + return handler; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java new file mode 100644 index 000000000..2f15e7868 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ActivityUtils.java @@ -0,0 +1,72 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import android.app.Activity; +import android.content.Intent; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; + +import org.mozilla.gecko.AppConstants.Versions; + +public class ActivityUtils { + private ActivityUtils() { + } + + public static void setFullScreen(Activity activity, boolean fullscreen) { + // Hide/show the system notification bar + Window window = activity.getWindow(); + + if (Versions.feature16Plus) { + int newVis; + if (fullscreen) { + newVis = View.SYSTEM_UI_FLAG_FULLSCREEN; + if (Versions.feature19Plus) { + newVis |= View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; + } else { + newVis |= View.SYSTEM_UI_FLAG_LOW_PROFILE; + } + } else { + newVis = View.SYSTEM_UI_FLAG_VISIBLE; + } + + window.getDecorView().setSystemUiVisibility(newVis); + } else { + window.setFlags(fullscreen ? + WindowManager.LayoutParams.FLAG_FULLSCREEN : 0, + WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + } + + public static boolean isFullScreen(final Activity activity) { + final Window window = activity.getWindow(); + + if (Versions.feature16Plus) { + final int vis = window.getDecorView().getSystemUiVisibility(); + return (vis & View.SYSTEM_UI_FLAG_FULLSCREEN) != 0; + } + + final int flags = window.getAttributes().flags; + return ((flags & WindowManager.LayoutParams.FLAG_FULLSCREEN) != 0); + } + + /** + * Finish this activity and launch the default home screen activity. + */ + public static void goToHomeScreen(Activity activity) { + Intent intent = new Intent(Intent.ACTION_MAIN); + + intent.addCategory(Intent.CATEGORY_HOME); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activity.startActivity(intent); + + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java new file mode 100644 index 000000000..9e9bb5a9e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.java @@ -0,0 +1,25 @@ +/* -*- 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.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.os.Bundle; + +@RobocopTarget +public interface BundleEventListener { + /** + * Handles a message sent from Gecko. + * + * @param event The name of the event being sent. + * @param message The message data. + * @param callback The callback interface for this message. A callback is provided only if the + * originating Messaging.sendRequest call included a callback argument; + * otherwise, callback will be null. All listeners for a given event are given + * the same callback object, and exactly one listener must handle the callback. + */ + void handleMessage(String event, Bundle message, EventCallback callback); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/Clipboard.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/Clipboard.java new file mode 100644 index 000000000..02b07674f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/Clipboard.java @@ -0,0 +1,117 @@ +/* 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.util; + +import java.util.concurrent.SynchronousQueue; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants.Versions; + +import android.content.ClipData; +import android.content.Context; +import android.util.Log; + +public final class Clipboard { + // Volatile but not synchronized: we don't care about the race condition in + // init, because both app contexts will be the same, but we do care about a + // thread having a stale null value of mContext. + volatile static Context mContext; + private final static String LOGTAG = "GeckoClipboard"; + private final static SynchronousQueue<String> sClipboardQueue = new SynchronousQueue<String>(); + + private Clipboard() { + } + + public static void init(final Context c) { + if (mContext != null) { + Log.w(LOGTAG, "Clipboard.init() called twice!"); + return; + } + mContext = c.getApplicationContext(); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getText() { + // If we're on the UI thread or the background thread, we have a looper on the thread + // and can just call this directly. For any other threads, post the call to the + // background thread. + + if (ThreadUtils.isOnUiThread() || ThreadUtils.isOnBackgroundThread()) { + return getClipboardTextImpl(); + } + + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + String text = getClipboardTextImpl(); + try { + sClipboardQueue.put(text != null ? text : ""); + } catch (InterruptedException ie) { } + } + }); + + try { + return sClipboardQueue.take(); + } catch (InterruptedException ie) { + return ""; + } + } + + @WrapForJNI(calledFrom = "gecko") + public static void setText(final CharSequence text) { + ThreadUtils.postToBackgroundThread(new Runnable() { + @Override + public void run() { + // In API Level 11 and above, CLIPBOARD_SERVICE returns android.content.ClipboardManager, + // which is a subclass of android.text.ClipboardManager. + final android.content.ClipboardManager cm = (android.content.ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + final ClipData clip = ClipData.newPlainText("Text", text); + try { + cm.setPrimaryClip(clip); + } catch (NullPointerException e) { + // Bug 776223: This is a Samsung clipboard bug. setPrimaryClip() can throw + // a NullPointerException if Samsung's /data/clipboard directory is full. + // Fortunately, the text is still successfully copied to the clipboard. + } + return; + } + }); + } + + /** + * @return true if the clipboard is nonempty, false otherwise. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean hasText() { + android.content.ClipboardManager cm = (android.content.ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + return cm.hasPrimaryClip(); + } + + /** + * Deletes all text from the clipboard. + */ + @WrapForJNI(calledFrom = "gecko") + public static void clearText() { + setText(null); + } + + /** + * On some devices, access to the clipboard service needs to happen + * on a thread with a looper, so this function requires a looper is + * present on the thread. + */ + @SuppressWarnings("deprecation") + static String getClipboardTextImpl() { + android.content.ClipboardManager cm = (android.content.ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm.hasPrimaryClip()) { + ClipData clip = cm.getPrimaryClip(); + if (clip != null) { + ClipData.Item item = clip.getItemAt(0); + return item.coerceToText(mContext).toString(); + } + } + return null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContextUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContextUtils.java new file mode 100644 index 000000000..3a37911b0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ContextUtils.java @@ -0,0 +1,51 @@ +/* + * 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.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.text.TextUtils; + +public class ContextUtils { + private static final String INSTALLER_GOOGLE_PLAY = "com.android.vending"; + + private ContextUtils() {} + + /** + * @return {@link android.content.pm.PackageInfo#firstInstallTime} for the context's package. + * @throws PackageManager.NameNotFoundException Unexpected - we get the package name from the context so + * it's expected to be found. + */ + public static PackageInfo getCurrentPackageInfo(final Context context) { + try { + return context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + } catch (PackageManager.NameNotFoundException e) { + throw new AssertionError("Should not happen: Can't get package info of own package"); + } + } + + public static boolean isPackageInstalled(final Context context, String packageName) { + try { + PackageManager pm = context.getPackageManager(); + pm.getPackageInfo(packageName, 0); + return true; + } catch (PackageManager.NameNotFoundException e) { + return false; + } + } + + public static boolean isInstalledFromGooglePlay(final Context context) { + final String installerPackageName = context.getPackageManager().getInstallerPackageName(context.getPackageName()); + + if (TextUtils.isEmpty(installerPackageName)) { + return false; + } + + return INSTALLER_GOOGLE_PLAY.equals(installerPackageName); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java new file mode 100644 index 000000000..9d34a0fe8 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DateUtil.java @@ -0,0 +1,55 @@ +/* + * 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.util; + +import android.support.annotation.NonNull; + +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; +import java.util.concurrent.TimeUnit; + +/** + * Utilities to help with manipulating Java's dates and calendars. + */ +public class DateUtil { + private DateUtil() {} + + /** + * @param date the date to convert to HTTP format + * @return the date as specified in rfc 1123, e.g. "Tue, 01 Feb 2011 14:00:00 GMT" + */ + public static String getDateInHTTPFormat(@NonNull final Date date) { + final DateFormat df = new SimpleDateFormat("E, dd MMM yyyy HH:mm:ss z", Locale.US); + df.setTimeZone(TimeZone.getTimeZone("GMT")); + return df.format(date); + } + + /** + * Returns the timezone offset for the current date in minutes. See + * {@link #getTimezoneOffsetInMinutesForGivenDate(Calendar)} for more details. + */ + public static int getTimezoneOffsetInMinutes(@NonNull final TimeZone timezone) { + return getTimezoneOffsetInMinutesForGivenDate(Calendar.getInstance(timezone)); + } + + /** + * Returns the time zone offset for the given date in minutes. The date makes a difference due to daylight + * savings time in some regions. We return minutes because we can accurately represent time zones that are + * offset by non-integer hour values, e.g. parts of New Zealand at UTC+12:45. + * + * @param calendar A calendar with the appropriate time zone & date already set. + */ + public static int getTimezoneOffsetInMinutesForGivenDate(@NonNull final Calendar calendar) { + // via Date.getTimezoneOffset deprecated docs (note: it had incorrect order of operations). + // Also, we cast to int because we should never overflow here - the max should be GMT+14 = 840. + return (int) TimeUnit.MILLISECONDS.toMinutes(calendar.get(Calendar.ZONE_OFFSET) + calendar.get(Calendar.DST_OFFSET)); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java new file mode 100644 index 000000000..099542666 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java @@ -0,0 +1,29 @@ +package org.mozilla.gecko.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +/** + * Callback interface for Gecko requests. + * + * For each instance of EventCallback, exactly one of sendResponse, sendError, or sendCancel + * must be called to prevent observer leaks. If more than one send* method is called, or if a + * single send method is called multiple times, an {@link IllegalStateException} will be thrown. + */ +@RobocopTarget +public interface EventCallback { + /** + * Sends a success response with the given data. + * + * @param response The response data to send to Gecko. Can be any of the types accepted by + * JSONObject#put(String, Object). + */ + public void sendSuccess(Object response); + + /** + * Sends an error response with the given data. + * + * @param response The response data to send to Gecko. Can be any of the types accepted by + * JSONObject#put(String, Object). + */ + public void sendError(Object response); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java new file mode 100644 index 000000000..01cdd42bb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FileUtils.java @@ -0,0 +1,259 @@ +/* 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.util; + +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.FilenameFilter; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.Charset; +import java.util.Comparator; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; + +public class FileUtils { + private static final String LOGTAG = "GeckoFileUtils"; + + /* + * A basic Filter for checking a filename and age. + **/ + static public class NameAndAgeFilter implements FilenameFilter { + final private String mName; + final private double mMaxAge; + + public NameAndAgeFilter(String name, double age) { + mName = name; + mMaxAge = age; + } + + @Override + public boolean accept(File dir, String filename) { + if (mName == null || mName.matches(filename)) { + File f = new File(dir, filename); + + if (mMaxAge < 0 || System.currentTimeMillis() - f.lastModified() > mMaxAge) { + return true; + } + } + + return false; + } + } + + @RobocopTarget + public static void delTree(File dir, FilenameFilter filter, boolean recurse) { + String[] files = null; + + if (filter != null) { + files = dir.list(filter); + } else { + files = dir.list(); + } + + if (files == null) { + return; + } + + for (String file : files) { + File f = new File(dir, file); + delete(f, recurse); + } + } + + public static boolean delete(File file) throws IOException { + return delete(file, true); + } + + public static boolean delete(File file, boolean recurse) { + if (file.isDirectory() && recurse) { + // If the quick delete failed and this is a dir, recursively delete the contents of the dir + String files[] = file.list(); + for (String temp : files) { + File fileDelete = new File(file, temp); + try { + delete(fileDelete); + } catch (IOException ex) { + Log.i(LOGTAG, "Error deleting " + fileDelete.getPath(), ex); + } + } + } + + // Even if this is a dir, it should now be empty and delete should work + return file.delete(); + } + + /** + * A generic solution to read a JSONObject from a file. See + * {@link #readStringFromFile(File)} for more details. + * + * @throws IOException if the file is empty, or another IOException occurs + * @throws JSONException if the file could not be converted to a JSONObject. + */ + public static JSONObject readJSONObjectFromFile(final File file) throws IOException, JSONException { + if (file.length() == 0) { + // Redirect this exception so it's clearer than when the JSON parser catches it. + throw new IOException("Given file is empty - the JSON parser cannot create an object from an empty file"); + } + return new JSONObject(readStringFromFile(file)); + } + + /** + * A generic solution to read from a file. For more details, + * see {@link #readStringFromInputStreamAndCloseStream(InputStream, int)}. + * + * This method loads the entire file into memory so will have the expected performance impact. + * If you're trying to read a large file, you should be handling your own reading to avoid + * out-of-memory errors. + */ + public static String readStringFromFile(final File file) throws IOException { + // FileInputStream will throw FileNotFoundException if the file does not exist, but + // File.length will return 0 if the file does not exist so we catch it sooner. + if (!file.exists()) { + throw new FileNotFoundException("Given file, " + file + ", does not exist"); + } else if (file.length() == 0) { + return ""; + } + final int len = (int) file.length(); // includes potential EOF character. + return readStringFromInputStreamAndCloseStream(new FileInputStream(file), len); + } + + /** + * A generic solution to read from an input stream in UTF-8. This function will read from the stream until it + * is finished and close the stream - this is necessary to close the wrapping resources. + * + * For a higher-level method, see {@link #readStringFromFile(File)}. + * + * Since this is generic, it may not be the most performant for your use case. + * + * @param bufferSize Size of the underlying buffer for read optimizations - must be > 0. + */ + public static String readStringFromInputStreamAndCloseStream(final InputStream inputStream, final int bufferSize) + throws IOException { + if (bufferSize <= 0) { + // Safe close: it's more important to alert the programmer of + // their error than to let them catch and continue on their way. + IOUtils.safeStreamClose(inputStream); + throw new IllegalArgumentException("Expected buffer size larger than 0. Got: " + bufferSize); + } + + final StringBuilder stringBuilder = new StringBuilder(bufferSize); + final InputStreamReader reader = new InputStreamReader(inputStream, Charset.forName("UTF-8")); + try { + int charsRead; + final char[] buffer = new char[bufferSize]; + while ((charsRead = reader.read(buffer, 0, bufferSize)) != -1) { + stringBuilder.append(buffer, 0, charsRead); + } + } finally { + reader.close(); + } + return stringBuilder.toString(); + } + + /** + * A generic solution to write a JSONObject to a file. + * See {@link #writeStringToFile(File, String)} for more details. + */ + public static void writeJSONObjectToFile(final File file, final JSONObject obj) throws IOException { + writeStringToFile(file, obj.toString()); + } + + /** + * A generic solution to write to a File - the given file will be overwritten. If it does not exist yet, it will + * be created. See {@link #writeStringToOutputStreamAndCloseStream(OutputStream, String)} for more details. + */ + public static void writeStringToFile(final File file, final String str) throws IOException { + writeStringToOutputStreamAndCloseStream(new FileOutputStream(file, false), str); + } + + /** + * A generic solution to write to an output stream in UTF-8. The stream will be closed at the + * completion of this method - it's necessary in order to close the wrapping resources. + * + * For a higher-level method, see {@link #writeStringToFile(File, String)}. + * + * Since this is generic, it may not be the most performant for your use case. + */ + public static void writeStringToOutputStreamAndCloseStream(final OutputStream outputStream, final String str) + throws IOException { + try { + final OutputStreamWriter writer = new OutputStreamWriter(outputStream, Charset.forName("UTF-8")); + try { + writer.write(str); + } finally { + writer.close(); + } + } finally { + // OutputStreamWriter.close can throw before closing the + // underlying stream. For safety, we close here too. + outputStream.close(); + } + } + + public static class FilenameWhitelistFilter implements FilenameFilter { + private final Set<String> mFilenameWhitelist; + + public FilenameWhitelistFilter(final Set<String> filenameWhitelist) { + mFilenameWhitelist = filenameWhitelist; + } + + @Override + public boolean accept(final File dir, final String filename) { + return mFilenameWhitelist.contains(filename); + } + } + + public static class FilenameRegexFilter implements FilenameFilter { + private final Pattern mPattern; + + // Each time `Pattern.matcher` is called, a new matcher is created. We can avoid the excessive object creation + // by caching the returned matcher and calling `Matcher.reset` on it. Since Matcher's are not thread safe, + // this assumes `FilenameFilter.accept` is not run in parallel (which, according to the source, it is not). + private Matcher mCachedMatcher; + + public FilenameRegexFilter(final Pattern pattern) { + mPattern = pattern; + } + + @Override + public boolean accept(final File dir, final String filename) { + if (mCachedMatcher == null) { + mCachedMatcher = mPattern.matcher(filename); + } else { + mCachedMatcher.reset(filename); + } + return mCachedMatcher.matches(); + } + } + + public static class FileLastModifiedComparator implements Comparator<File> { + @Override + public int compare(final File lhs, final File rhs) { + // Long.compare is API 19+. + final long lhsModified = lhs.lastModified(); + final long rhsModified = rhs.lastModified(); + if (lhsModified < rhsModified) { + return -1; + } else if (lhsModified == rhsModified) { + return 0; + } else { + return 1; + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java new file mode 100644 index 000000000..fbcd7254f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/FloatUtils.java @@ -0,0 +1,43 @@ +/* -*- 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.util; + +import android.graphics.PointF; + +import java.lang.IllegalArgumentException; + +public final class FloatUtils { + private FloatUtils() {} + + public static boolean fuzzyEquals(float a, float b) { + return (Math.abs(a - b) < 1e-6); + } + + public static boolean fuzzyEquals(PointF a, PointF b) { + return fuzzyEquals(a.x, b.x) && fuzzyEquals(a.y, b.y); + } + + /* + * Returns the value that represents a linear transition between `from` and `to` at time `t`, + * which is on the scale [0, 1). Thus with t = 0.0f, this returns `from`; with t = 1.0f, this + * returns `to`; with t = 0.5f, this returns the value halfway from `from` to `to`. + */ + public static float interpolate(float from, float to, float t) { + return from + (to - from) * t; + } + + /** + * Returns 'value', clamped so that it isn't any lower than 'low', and it + * isn't any higher than 'high'. + */ + public static float clamp(float value, float low, float high) { + if (high < low) { + throw new IllegalArgumentException( + "clamp called with invalid parameters (" + high + " < " + low + ")" ); + } + return Math.max(low, Math.min(high, value)); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java new file mode 100644 index 000000000..e22be8fd8 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GamepadUtils.java @@ -0,0 +1,140 @@ +/* -*- 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.util; + +import android.annotation.TargetApi; +import android.os.Build; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; + +public final class GamepadUtils { + private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611; + + private static View.OnKeyListener sClickDispatcher; + private static float sDeadZoneThresholdOverride = 1e-2f; + + private GamepadUtils() { + } + + @TargetApi(Build.VERSION_CODES.HONEYCOMB_MR1) + private static boolean isGamepadKey(KeyEvent event) { + if (Build.VERSION.SDK_INT < 12) { + return false; + } + return (event.getSource() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD; + } + + public static boolean isActionKey(KeyEvent event) { + return (isGamepadKey(event) && (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_A)); + } + + public static boolean isActionKeyDown(KeyEvent event) { + return isActionKey(event) && event.getAction() == KeyEvent.ACTION_DOWN; + } + + public static boolean isBackKey(KeyEvent event) { + return (isGamepadKey(event) && (event.getKeyCode() == KeyEvent.KEYCODE_BUTTON_B)); + } + + public static void overrideDeadZoneThreshold(float threshold) { + sDeadZoneThresholdOverride = threshold; + } + + public static boolean isValueInDeadZone(MotionEvent event, int axis) { + float threshold; + if (sDeadZoneThresholdOverride >= 0) { + threshold = sDeadZoneThresholdOverride; + } else { + InputDevice.MotionRange range = event.getDevice().getMotionRange(axis); + threshold = range.getFlat() + range.getFuzz(); + } + float value = event.getAxisValue(axis); + return (Math.abs(value) < threshold); + } + + public static boolean isPanningControl(MotionEvent event) { + if (Build.VERSION.SDK_INT < 12) { + return false; + } + if ((event.getSource() & InputDevice.SOURCE_CLASS_MASK) != InputDevice.SOURCE_CLASS_JOYSTICK) { + return false; + } + if (isValueInDeadZone(event, MotionEvent.AXIS_X) + && isValueInDeadZone(event, MotionEvent.AXIS_Y) + && isValueInDeadZone(event, MotionEvent.AXIS_Z) + && isValueInDeadZone(event, MotionEvent.AXIS_RZ)) { + return false; + } + return true; + } + + public static View.OnKeyListener getClickDispatcher() { + if (sClickDispatcher == null) { + sClickDispatcher = new View.OnKeyListener() { + @Override + public boolean onKey(View v, int keyCode, KeyEvent event) { + if (isActionKeyDown(event)) { + return v.performClick(); + } + return false; + } + }; + } + return sClickDispatcher; + } + + public static KeyEvent translateSonyXperiaGamepadKeys(int keyCode, KeyEvent event) { + // The cross and circle button mappings may be swapped in the different regions so + // determine if they are swapped so the proper key codes can be mapped to the keys + boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped(); + + // If a Sony Xperia, remap the cross and circle buttons to buttons + // A and B for the gamepad API + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: + keyCode = (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B); + break; + + case KeyEvent.KEYCODE_DPAD_CENTER: + keyCode = (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A); + break; + + default: + return event; + } + + return new KeyEvent(event.getAction(), keyCode); + } + + public static boolean isSonyXperiaGamepadKeyEvent(KeyEvent event) { + return (event.getDeviceId() == SONY_XPERIA_GAMEPAD_DEVICE_ID && + "Sony Ericsson".equals(Build.MANUFACTURER) && + ("R800".equals(Build.MODEL) || "R800i".equals(Build.MODEL))); + } + + private static boolean areSonyXperiaGamepadKeysSwapped() { + // The cross and circle buttons on Sony Xperia phones are swapped + // in different regions + // http://developer.sonymobile.com/2011/02/13/xperia-play-game-keys/ + final char DEFAULT_O_BUTTON_LABEL = 0x25CB; + + boolean swapped = false; + int[] deviceIds = InputDevice.getDeviceIds(); + + for (int i = 0; deviceIds != null && i < deviceIds.length; i++) { + KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]); + if (keyCharacterMap != null && DEFAULT_O_BUTTON_LABEL == + keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) { + swapped = true; + break; + } + } + return swapped; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java new file mode 100644 index 000000000..442f782e2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java @@ -0,0 +1,76 @@ +/* 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.util; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.SynchronousQueue; + +final class GeckoBackgroundThread extends Thread { + private static final String LOOPER_NAME = "GeckoBackgroundThread"; + + // Guarded by 'GeckoBackgroundThread.class'. + private static Handler handler; + private static Thread thread; + + // The initial Runnable to run on the new thread. Its purpose + // is to avoid us having to wait for the new thread to start. + private Runnable initialRunnable; + + // Singleton, so private constructor. + private GeckoBackgroundThread(final Runnable initialRunnable) { + this.initialRunnable = initialRunnable; + } + + @Override + public void run() { + setName(LOOPER_NAME); + Looper.prepare(); + + synchronized (GeckoBackgroundThread.class) { + handler = new Handler(); + GeckoBackgroundThread.class.notify(); + } + + if (initialRunnable != null) { + initialRunnable.run(); + initialRunnable = null; + } + + Looper.loop(); + } + + private static void startThread(final Runnable initialRunnable) { + thread = new GeckoBackgroundThread(initialRunnable); + ThreadUtils.setBackgroundThread(thread); + + thread.setDaemon(true); + thread.start(); + } + + // Get a Handler for a looper thread, or create one if it doesn't yet exist. + /*package*/ static synchronized Handler getHandler() { + if (thread == null) { + startThread(null); + } + + while (handler == null) { + try { + GeckoBackgroundThread.class.wait(); + } catch (final InterruptedException e) { + } + } + return handler; + } + + /*package*/ static synchronized void post(final Runnable runnable) { + if (thread == null) { + startThread(runnable); + return; + } + getHandler().post(runnable); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoEventListener.java new file mode 100644 index 000000000..10336490b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoEventListener.java @@ -0,0 +1,14 @@ +/* -*- 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.util; + +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; + +@RobocopTarget +public interface GeckoEventListener { + void handleMessage(String event, JSONObject message); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoJarReader.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoJarReader.java new file mode 100644 index 000000000..4e11592a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoJarReader.java @@ -0,0 +1,261 @@ +/* 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.util; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.BitmapDrawable; +import android.util.Log; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.mozglue.NativeZip; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Stack; + +/* Reads out of a multiple level deep jar file such as + * jar:jar:file:///data/app/org.mozilla.fennec.apk!/omni.ja!/chrome/chrome/content/branding/favicon32.png + */ +public final class GeckoJarReader { + private static final String LOGTAG = "GeckoJarReader"; + + private GeckoJarReader() {} + + public static Bitmap getBitmap(Context context, Resources resources, String url) { + BitmapDrawable drawable = getBitmapDrawable(context, resources, url); + return (drawable != null) ? drawable.getBitmap() : null; + } + + public static BitmapDrawable getBitmapDrawable(Context context, Resources resources, + String url) { + Stack<String> jarUrls = parseUrl(url); + InputStream inputStream = null; + BitmapDrawable bitmap = null; + + NativeZip zip = null; + try { + // Load the initial jar file as a zip + zip = getZipFile(context, jarUrls.pop()); + inputStream = getStream(zip, jarUrls, url); + if (inputStream != null) { + bitmap = new BitmapDrawable(resources, inputStream); + // BitmapDrawable created from a stream does not set the correct target density from resources. + // In fact it discards the resources https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/graphics/java/android/graphics/drawable/BitmapDrawable.java#191 + bitmap.setTargetDensity(resources.getDisplayMetrics()); + } + } catch (IOException | URISyntaxException ex) { + Log.e(LOGTAG, "Exception ", ex); + } finally { + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException ex) { + Log.e(LOGTAG, "Error closing stream", ex); + } + } + } + + return bitmap; + } + + public static String getText(Context context, String url) { + Stack<String> jarUrls = parseUrl(url); + + NativeZip zip = null; + BufferedReader reader = null; + String text = null; + try { + zip = getZipFile(context, jarUrls.pop()); + InputStream input = getStream(zip, jarUrls, url); + if (input != null) { + reader = new BufferedReader(new InputStreamReader(input)); + text = reader.readLine(); + } + } catch (IOException | URISyntaxException ex) { + Log.e(LOGTAG, "Exception ", ex); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ex) { + Log.e(LOGTAG, "Error closing reader", ex); + } + } + } + + return text; + } + + private static NativeZip getZipFile(Context context, String url) + throws IOException, URISyntaxException { + URI fileUrl = new URI(url); + GeckoLoader.loadMozGlue(context); + return new NativeZip(fileUrl.getPath()); + } + + @RobocopTarget + /** + * Extract a (possibly nested) file from an archive and write it to a temporary file. + * + * @param context Android context. + * @param url to open. Can include jar: to "reach into" nested archives. + * @param dir to write temporary file to. + * @return a <code>File</code>, if one could be written; otherwise null. + * @throws IOException if an error occured. + */ + public static File extractStream(Context context, String url, File dir, String suffix) throws IOException { + InputStream input = null; + try { + try { + final URI fileURI = new URI(url); + // We don't check the scheme because we want to catch bare files, not just file:// URIs. + // If we let bare files through, we'd try to open them as ZIP files later -- and crash in native code. + if (fileURI != null && fileURI.getPath() != null) { + final File inputFile = new File(fileURI.getPath()); + if (inputFile != null && inputFile.exists()) { + input = new FileInputStream(inputFile); + } + } + } catch (URISyntaxException e) { + // Not a file:// URI. + } + if (input == null) { + // No luck with file:// URI; maybe some other URI? + input = getStream(context, url); + } + if (input == null) { + // Not found! + return null; + } + + // n.b.: createTempFile does not in fact delete the file. + final File file = File.createTempFile("extractStream", suffix, dir); + OutputStream output = null; + try { + output = new FileOutputStream(file); + byte[] buf = new byte[8192]; + int len; + while ((len = input.read(buf)) >= 0) { + output.write(buf, 0, len); + } + return file; + } finally { + if (output != null) { + output.close(); + } + } + } finally { + if (input != null) { + try { + input.close(); + } catch (IOException e) { + Log.w(LOGTAG, "Got exception closing stream; ignoring.", e); + } + } + } + } + + @RobocopTarget + public static InputStream getStream(Context context, String url) { + Stack<String> jarUrls = parseUrl(url); + try { + NativeZip zip = getZipFile(context, jarUrls.pop()); + return getStream(zip, jarUrls, url); + } catch (Exception ex) { + // Some JNI code throws IllegalArgumentException on a bad file name; + // swallow the error and return null. We could also see legitimate + // IOExceptions here. + Log.e(LOGTAG, "Exception getting input stream from jar URL: " + url, ex); + return null; + } + } + + private static InputStream getStream(NativeZip zip, Stack<String> jarUrls, String origUrl) { + InputStream inputStream = null; + + // loop through children jar files until we reach the innermost one + while (!jarUrls.empty()) { + String fileName = jarUrls.pop(); + + if (inputStream != null) { + // intermediate NativeZips and InputStreams will be garbage collected. + try { + zip = new NativeZip(inputStream); + } catch (IllegalArgumentException e) { + String description = "!!! BUG 849589 !!! origUrl=" + origUrl; + Log.e(LOGTAG, description, e); + throw new IllegalArgumentException(description); + } + } + + inputStream = zip.getInputStream(fileName); + if (inputStream == null) { + Log.d(LOGTAG, "No Entry for " + fileName); + return null; + } + } + + return inputStream; + } + + /* Returns a stack of strings breaking the url up into pieces. Each piece + * is assumed to point to a jar file except for the final one. Callers should + * pass in the url to parse, and null for the parent parameter (used for recursion) + * For example, jar:jar:file:///data/app/org.mozilla.fennec.apk!/omni.ja!/chrome/chrome/content/branding/favicon32.png + * will return: + * file:///data/app/org.mozilla.fennec.apk + * omni.ja + * chrome/chrome/content/branding/favicon32.png + */ + private static Stack<String> parseUrl(String url) { + return parseUrl(url, null); + } + + private static Stack<String> parseUrl(String url, Stack<String> results) { + if (results == null) { + results = new Stack<String>(); + } + + if (url.startsWith("jar:")) { + int jarEnd = url.lastIndexOf("!"); + String subStr = url.substring(4, jarEnd); + results.push(url.substring(jarEnd + 2)); // remove the !/ characters + return parseUrl(subStr, results); + } else { + results.push(url); + return results; + } + } + + public static String getJarURL(Context context, String pathInsideJAR) { + // We need to encode the package resource path, because it might contain illegal characters. For example: + // /mnt/asec2/[2]org.mozilla.fennec-1/pkg.apk + // The round-trip through a URI does this for us. + final String resourcePath = context.getPackageResourcePath(); + return computeJarURI(resourcePath, pathInsideJAR); + } + + /** + * Encodes its resource path correctly. + */ + @RobocopTarget + public static String computeJarURI(String resourcePath, String pathInsideJAR) { + final String resURI = new File(resourcePath).toURI().toString(); + + // TODO: do we need to encode the file path, too? + return "jar:jar:" + resURI + "!/" + AppConstants.OMNIJAR_NAME + "!/" + pathInsideJAR; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoRequest.java new file mode 100644 index 000000000..a57ed7f08 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoRequest.java @@ -0,0 +1,94 @@ +/* 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.util; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.json.JSONException; +import org.json.JSONObject; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import android.util.Log; + +public abstract class GeckoRequest { + private static final String LOGTAG = "GeckoRequest"; + private static final AtomicInteger currentId = new AtomicInteger(0); + + private final int id = currentId.getAndIncrement(); + private final String name; + private final String data; + + /** + * Creates a request that can be dispatched using + * {@link GeckoAppShell#sendRequestToGecko(GeckoRequest)}. + * + * @param name The name of the event associated with this request, which must have a + * Gecko-side listener registered to respond to this request. + * @param data Data to send with this request, which can be any object serializable by + * {@link JSONObject#put(String, Object)}. + */ + @RobocopTarget + public GeckoRequest(String name, Object data) { + this.name = name; + final JSONObject message = new JSONObject(); + try { + message.put("id", id); + message.put("data", data); + } catch (JSONException e) { + Log.e(LOGTAG, "JSON error", e); + } + this.data = message.toString(); + } + + /** + * Gets the ID for this request. + * + * @return The request ID + */ + public int getId() { + return id; + } + + /** + * Gets the event name associated with this request. + * + * @return The name of the event sent to Gecko + */ + public String getName() { + return name; + } + + /** + * Gets the stringified data associated with this request. + * + * @return The data being sent with the request + */ + public String getData() { + return data; + } + + /** + * Callback executed when the request succeeds. + * + * @param nativeJSObject The response data from Gecko + */ + @RobocopTarget + public abstract void onResponse(NativeJSObject nativeJSObject); + + /** + * Callback executed when the request fails. + * + * By default, an exception is thrown. This should be overridden if the + * GeckoRequest is able to recover from the error. + * + * @throws RuntimeException + */ + @RobocopTarget + public void onError(NativeJSObject error) { + final String message = error.optString("message", "<no message>"); + final String stack = error.optString("stack", "<no stack>"); + throw new RuntimeException("Unhandled error for GeckoRequest " + name + ": " + message + "\nJS stack:\n" + stack); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java new file mode 100644 index 000000000..864462d9b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java @@ -0,0 +1,169 @@ +/* -*- 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.util; + +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.AppConstants.Versions; + +import android.media.MediaCodecInfo; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecList; +import android.util.Log; + +public final class HardwareCodecCapabilityUtils { + private static final String LOGTAG = "GeckoHardwareCodecCapabilityUtils"; + + // List of supported HW VP8 encoders. + private static final String[] supportedVp8HwEncCodecPrefixes = + {"OMX.qcom.", "OMX.Intel." }; + // List of supported HW VP8 decoders. + private static final String[] supportedVp8HwDecCodecPrefixes = + {"OMX.qcom.", "OMX.Nvidia.", "OMX.Exynos.", "OMX.Intel." }; + private static final String VP8_MIME_TYPE = "video/x-vnd.on2.vp8"; + private static final String VP9_MIME_TYPE = "video/x-vnd.on2.vp9"; + // NV12 color format supported by QCOM codec, but not declared in MediaCodec - + // see /hardware/qcom/media/mm-core/inc/OMX_QCOMExtns.h + private static final int + COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m = 0x7FA30C04; + // Allowable color formats supported by codec - in order of preference. + private static final int[] supportedColorList = { + CodecCapabilities.COLOR_FormatYUV420Planar, + CodecCapabilities.COLOR_FormatYUV420SemiPlanar, + CodecCapabilities.COLOR_QCOM_FormatYUV420SemiPlanar, + COLOR_QCOM_FORMATYUV420PackedSemiPlanar32m + }; + + @WrapForJNI + public static boolean findDecoderCodecInfoForMimeType(String aMimeType) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder()) { + continue; + } + for (String mimeType : info.getSupportedTypes()) { + if (mimeType.equals(aMimeType)) { + return true; + } + } + } + return false; + } + + public static boolean getHWEncoderCapability() { + if (Versions.feature20Plus) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (!info.isEncoder()) { + continue; + } + String name = null; + for (String mimeType : info.getSupportedTypes()) { + if (mimeType.equals(VP8_MIME_TYPE)) { + name = info.getName(); + break; + } + } + if (name == null) { + continue; // No HW support in this codec; try the next one. + } + Log.e(LOGTAG, "Found candidate encoder " + name); + + // Check if this is supported encoder. + boolean supportedCodec = false; + for (String codecPrefix : supportedVp8HwEncCodecPrefixes) { + if (name.startsWith(codecPrefix)) { + supportedCodec = true; + break; + } + } + if (!supportedCodec) { + continue; + } + + // Check if codec supports either yuv420 or nv12. + CodecCapabilities capabilities = + info.getCapabilitiesForType(VP8_MIME_TYPE); + for (int colorFormat : capabilities.colorFormats) { + Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat)); + } + for (int supportedColorFormat : supportedColorList) { + for (int codecColorFormat : capabilities.colorFormats) { + if (codecColorFormat == supportedColorFormat) { + // Found supported HW Encoder. + Log.e(LOGTAG, "Found target encoder " + name + + ". Color: 0x" + Integer.toHexString(codecColorFormat)); + return true; + } + } + } + } + } + // No HW encoder. + return false; + } + + public static boolean getHWDecoderCapability() { + return getHWDecoderCapability(VP8_MIME_TYPE); + } + + @WrapForJNI + public static boolean HasHWVP9() { + return getHWDecoderCapability(VP9_MIME_TYPE); + } + + public static boolean getHWDecoderCapability(String aMimeType) { + if (Versions.feature20Plus) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder()) { + continue; + } + String name = null; + for (String mimeType : info.getSupportedTypes()) { + if (mimeType.equals(aMimeType)) { + name = info.getName(); + break; + } + } + if (name == null) { + continue; // No HW support in this codec; try the next one. + } + Log.e(LOGTAG, "Found candidate decoder " + name); + + // Check if this is supported decoder. + boolean supportedCodec = false; + for (String codecPrefix : supportedVp8HwDecCodecPrefixes) { + if (name.startsWith(codecPrefix)) { + supportedCodec = true; + break; + } + } + if (!supportedCodec) { + continue; + } + + // Check if codec supports either yuv420 or nv12. + CodecCapabilities capabilities = + info.getCapabilitiesForType(aMimeType); + for (int colorFormat : capabilities.colorFormats) { + Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat)); + } + for (int supportedColorFormat : supportedColorList) { + for (int codecColorFormat : capabilities.colorFormats) { + if (codecColorFormat == supportedColorFormat) { + // Found supported HW decoder. + Log.e(LOGTAG, "Found target decoder " + name + + ". Color: 0x" + Integer.toHexString(codecColorFormat)); + return true; + } + } + } + } + } + return false; // No HW decoder. + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java new file mode 100644 index 000000000..ba92d08cb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java @@ -0,0 +1,117 @@ +/* -*- 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.util; + +import org.mozilla.gecko.AppConstants; +import org.mozilla.gecko.SysInfo; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.Build; +import android.util.Log; +import android.view.ViewConfiguration; + +public final class HardwareUtils { + private static final String LOGTAG = "GeckoHardwareUtils"; + + private static final boolean IS_AMAZON_DEVICE = Build.MANUFACTURER.equalsIgnoreCase("Amazon"); + public static final boolean IS_KINDLE_DEVICE = IS_AMAZON_DEVICE && + (Build.MODEL.equals("Kindle Fire") || + Build.MODEL.startsWith("KF")); + + private static volatile boolean sInited; + + // These are all set once, during init. + private static volatile boolean sIsLargeTablet; + private static volatile boolean sIsSmallTablet; + private static volatile boolean sIsTelevision; + + private HardwareUtils() { + } + + public static void init(Context context) { + if (sInited) { + // This is unavoidable, given that HardwareUtils is called from background services. + Log.d(LOGTAG, "HardwareUtils already inited."); + return; + } + + // Pre-populate common flags from the context. + final int screenLayoutSize = context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK; + if (Build.VERSION.SDK_INT >= 11) { + if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_XLARGE) { + sIsLargeTablet = true; + } else if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_LARGE) { + sIsSmallTablet = true; + } + if (Build.VERSION.SDK_INT >= 16) { + if (context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEVISION)) { + sIsTelevision = true; + } + } + } + + sInited = true; + } + + public static boolean isTablet() { + return sIsLargeTablet || sIsSmallTablet; + } + + public static boolean isLargeTablet() { + return sIsLargeTablet; + } + + public static boolean isSmallTablet() { + return sIsSmallTablet; + } + + public static boolean isTelevision() { + return sIsTelevision; + } + + public static int getMemSize() { + return SysInfo.getMemSize(); + } + + public static boolean isARMSystem() { + return Build.CPU_ABI != null && Build.CPU_ABI.equals("armeabi-v7a"); + } + + public static boolean isX86System() { + return Build.CPU_ABI != null && Build.CPU_ABI.equals("x86"); + } + + /** + * @return false if the current system is not supported (e.g. APK/system ABI mismatch). + */ + public static boolean isSupportedSystem() { + if (Build.VERSION.SDK_INT < AppConstants.Versions.MIN_SDK_VERSION || + Build.VERSION.SDK_INT > AppConstants.Versions.MAX_SDK_VERSION) { + return false; + } + + // See http://developer.android.com/ndk/guides/abis.html + final boolean isSystemARM = isARMSystem(); + final boolean isSystemX86 = isX86System(); + + final boolean isAppARM = AppConstants.ANDROID_CPU_ARCH.startsWith("armeabi-v7a"); + final boolean isAppX86 = AppConstants.ANDROID_CPU_ARCH.startsWith("x86"); + + // Only reject known incompatible ABIs. Better safe than sorry. + if ((isSystemX86 && isAppARM) || (isSystemARM && isAppX86)) { + return false; + } + + if ((isSystemX86 && isAppX86) || (isSystemARM && isAppARM)) { + return true; + } + + Log.w(LOGTAG, "Unknown app/system ABI combination: " + AppConstants.MOZ_APP_ABI + " / " + Build.CPU_ABI); + return true; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java new file mode 100644 index 000000000..ed0706320 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INIParser.java @@ -0,0 +1,176 @@ +/* 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.util; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.util.Enumeration; +import java.util.Hashtable; + +public final class INIParser extends INISection { + // default file to read and write to + private final File mFile; + + // List of sections in the current iniFile. null if the file has not been parsed yet + private Hashtable<String, INISection> mSections; + + // create a parser. The file will not be read until you attempt to + // access sections or properties inside it. At that point its read synchronously + public INIParser(File iniFile) { + super(""); + mFile = iniFile; + } + + // write ini data to the default file. Will overwrite anything current inside + public void write() { + writeTo(mFile); + } + + // write to the specified file. Will overwrite anything current inside + public void writeTo(File f) { + if (f == null) + return; + + FileWriter outputStream = null; + try { + outputStream = new FileWriter(f); + } catch (IOException e1) { + e1.printStackTrace(); + } + + BufferedWriter writer = new BufferedWriter(outputStream); + try { + write(writer); + writer.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void write(BufferedWriter writer) throws IOException { + super.write(writer); + + if (mSections != null) { + for (Enumeration<INISection> e = mSections.elements(); e.hasMoreElements();) { + INISection section = e.nextElement(); + section.write(writer); + writer.newLine(); + } + } + } + + // return all of the sections inside this file + public Hashtable<String, INISection> getSections() { + if (mSections == null) { + try { + parse(); + } catch (IOException e) { + debug("Error parsing: " + e); + } + } + return mSections; + } + + // parse the default file + @Override + protected void parse() throws IOException { + super.parse(); + parse(mFile); + } + + // parse a passed in file + private void parse(File f) throws IOException { + // Set up internal data members + mSections = new Hashtable<String, INISection>(); + + if (f == null || !f.exists()) + return; + + FileReader inputStream = null; + try { + inputStream = new FileReader(f); + } catch (FileNotFoundException e1) { + // If the file doesn't exist. Just return; + return; + } + + BufferedReader buf = new BufferedReader(inputStream); + String line = null; // current line of text we are parsing + INISection currentSection = null; // section we are currently parsing + + while ((line = buf.readLine()) != null) { + + if (line != null) + line = line.trim(); + + // blank line or a comment. ignore it + if (line == null || line.length() == 0 || line.charAt(0) == ';') { + debug("Ignore line: " + line); + } else if (line.charAt(0) == '[') { + debug("Parse as section: " + line); + currentSection = new INISection(line.substring(1, line.length() - 1)); + mSections.put(currentSection.getName(), currentSection); + } else { + debug("Parse as property: " + line); + + String[] pieces = line.split("="); + if (pieces.length != 2) + continue; + + String key = pieces[0].trim(); + String value = pieces[1].trim(); + if (currentSection != null) { + currentSection.setProperty(key, value); + } else { + mProperties.put(key, value); + } + } + } + buf.close(); + } + + // add a section to the file + public void addSection(INISection sect) { + // ensure that we have parsed the file + getSections(); + mSections.put(sect.getName(), sect); + } + + // get a section from the file. will return null if the section doesn't exist + public INISection getSection(String key) { + // ensure that we have parsed the file + getSections(); + return mSections.get(key); + } + + // remove an entire section from the file + public void removeSection(String name) { + // ensure that we have parsed the file + getSections(); + mSections.remove(name); + } + + // rename a section; nuking any previous section with the new + // name in the process + public void renameSection(String oldName, String newName) { + // ensure that we have parsed the file + getSections(); + + mSections.remove(newName); + INISection section = mSections.get(oldName); + if (section == null) + return; + + section.setName(newName); + mSections.remove(oldName); + mSections.put(newName, section); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java new file mode 100644 index 000000000..af91ad410 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/INISection.java @@ -0,0 +1,123 @@ +/* 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.util; + +import android.text.TextUtils; +import android.util.Log; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.util.Enumeration; +import java.util.Hashtable; + +public class INISection { + private static final String LOGTAG = "INIParser"; + + // default file to read and write to + private String mName; + public String getName() { return mName; } + public void setName(String name) { mName = name; } + + // show or hide debug logging + private boolean mDebug; + + // Global properties that aren't inside a section in the file + protected Hashtable<String, Object> mProperties; + + // create a parser. The file will not be read until you attempt to + // access sections or properties inside it. At that point its read synchronously + public INISection(String name) { + mName = name; + } + + // log a debug string to the console + protected void debug(String msg) { + if (mDebug) { + Log.i(LOGTAG, msg); + } + } + + // get a global property out of the hash table. will return null if the property doesn't exist + public Object getProperty(String key) { + getProperties(); // ensure that we have parsed the file + return mProperties.get(key); + } + + // get a global property out of the hash table. will return null if the property doesn't exist + public int getIntProperty(String key) { + Object val = getProperty(key); + if (val == null) + return -1; + + return Integer.parseInt(val.toString()); + } + + // get a global property out of the hash table. will return null if the property doesn't exist + public String getStringProperty(String key) { + Object val = getProperty(key); + if (val == null) + return null; + + return val.toString(); + } + + // get a hashtable of all the global properties in this file + public Hashtable<String, Object> getProperties() { + if (mProperties == null) { + try { + parse(); + } catch (IOException e) { + debug("Error parsing: " + e); + } + } + return mProperties; + } + + // do nothing for generic sections + protected void parse() throws IOException { + mProperties = new Hashtable<String, Object>(); + } + + // set a property. Will erase the property if value = null + public void setProperty(String key, Object value) { + getProperties(); // ensure that we have parsed the file + if (value == null) + removeProperty(key); + else + mProperties.put(key.trim(), value); + } + + // remove a property + public void removeProperty(String name) { + // ensure that we have parsed the file + getProperties(); + mProperties.remove(name); + } + + public void write(BufferedWriter writer) throws IOException { + if (!TextUtils.isEmpty(mName)) { + writer.write("[" + mName + "]"); + writer.newLine(); + } + + if (mProperties != null) { + for (Enumeration<String> e = mProperties.keys(); e.hasMoreElements();) { + String key = e.nextElement(); + writeProperty(writer, key, mProperties.get(key)); + } + } + writer.newLine(); + } + + // Helper function to write out a property + private void writeProperty(BufferedWriter writer, String key, Object value) { + try { + writer.write(key + "=" + value); + writer.newLine(); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java new file mode 100644 index 000000000..62eee5192 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IOUtils.java @@ -0,0 +1,129 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import android.util.Log; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * Static helper class containing useful methods for manipulating IO objects. + */ +public class IOUtils { + private static final String LOGTAG = "GeckoIOUtils"; + + /** + * Represents the result of consuming an input stream, holding the returned data as well + * as the length of the data returned. + * The byte[] is not guaranteed to be trimmed to the size of the data acquired from the stream: + * hence the need for the length field. This strategy avoids the need to copy the data into a + * trimmed buffer after consumption. + */ + public static class ConsumedInputStream { + public final int consumedLength; + // Only reassigned in getTruncatedData. + private byte[] consumedData; + + public ConsumedInputStream(int consumedLength, byte[] consumedData) { + this.consumedLength = consumedLength; + this.consumedData = consumedData; + } + + /** + * Get the data trimmed to the length of the actual payload read, caching the result. + */ + public byte[] getTruncatedData() { + if (consumedData.length == consumedLength) { + return consumedData; + } + + consumedData = truncateBytes(consumedData, consumedLength); + return consumedData; + } + + public byte[] getData() { + return consumedData; + } + } + + /** + * Fully read an InputStream into a byte array. + * @param iStream the InputStream to consume. + * @param bufferSize The initial size of the buffer to allocate. It will be grown as + * needed, but if the caller knows something about the InputStream then + * passing a good value here can improve performance. + */ + public static ConsumedInputStream readFully(InputStream iStream, int bufferSize) { + // Allocate a buffer to hold the raw data downloaded. + byte[] buffer = new byte[bufferSize]; + + // The offset of the start of the buffer's free space. + int bPointer = 0; + + // The quantity of bytes the last call to read yielded. + int lastRead = 0; + try { + // Fully read the data into the buffer. + while (lastRead != -1) { + // Read as many bytes as are currently available into the buffer. + lastRead = iStream.read(buffer, bPointer, buffer.length - bPointer); + bPointer += lastRead; + + // If buffer has overflowed, double its size and carry on. + if (bPointer == buffer.length) { + bufferSize *= 2; + byte[] newBuffer = new byte[bufferSize]; + + // Copy the contents of the old buffer into the new buffer. + System.arraycopy(buffer, 0, newBuffer, 0, buffer.length); + buffer = newBuffer; + } + } + + return new ConsumedInputStream(bPointer + 1, buffer); + } catch (IOException e) { + Log.e(LOGTAG, "Error consuming input stream.", e); + } finally { + try { + iStream.close(); + } catch (IOException e) { + Log.e(LOGTAG, "Error closing input stream.", e); + } + } + + return null; + } + + /** + * Truncate a given byte[] to a given length. Returns a new byte[] with the first length many + * bytes of the input. + */ + public static byte[] truncateBytes(byte[] bytes, int length) { + byte[] newBytes = new byte[length]; + System.arraycopy(bytes, 0, newBytes, 0, length); + + return newBytes; + } + + public static void safeStreamClose(Closeable stream) { + try { + if (stream != null) + stream.close(); + } catch (IOException e) { } + } + + public static void copy(InputStream in, OutputStream out) throws IOException { + byte[] buffer = new byte[4096]; + int len; + + while ((len = in.read(buffer)) != -1) { + out.write(buffer, 0, len); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputOptionsUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputOptionsUtils.java new file mode 100644 index 000000000..55c02e4da --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputOptionsUtils.java @@ -0,0 +1,45 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import android.content.Context; +import android.content.Intent; +import android.speech.RecognizerIntent; + +public class InputOptionsUtils { + public static boolean supportsVoiceRecognizer(Context context, String prompt) { + final Intent intent = createVoiceRecognizerIntent(prompt); + return intent.resolveActivity(context.getPackageManager()) != null; + } + + public static Intent createVoiceRecognizerIntent(String prompt) { + final Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH); + intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_WEB_SEARCH); + intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1); + intent.putExtra(RecognizerIntent.EXTRA_PROMPT, prompt); + return intent; + } + + public static boolean supportsIntent(Intent intent, Context context) { + return intent.resolveActivity(context.getPackageManager()) != null; + } + + public static boolean supportsQrCodeReader(Context context) { + final Intent intent = createQRCodeReaderIntent(); + return supportsIntent(intent, context); + } + + public static Intent createQRCodeReaderIntent() { + // Bug 602818 enables QR code input if you have the particular app below installed in your device + final String appPackage = "com.google.zxing.client.android"; + + Intent intent = new Intent(appPackage + ".SCAN"); + intent.setPackage(appPackage); + intent.putExtra("SCAN_MODE", "QR_CODE_MODE"); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + return intent; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java new file mode 100644 index 000000000..d4fe297da --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.java @@ -0,0 +1,109 @@ +/* + * 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.util; + +import android.content.Intent; +import android.os.Bundle; +import android.support.annotation.CheckResult; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import org.mozilla.gecko.mozglue.SafeIntent; + +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Utilities for Intents. + */ +public class IntentUtils { + public static final String ENV_VAR_IN_AUTOMATION = "MOZ_IN_AUTOMATION"; + + private static final String ENV_VAR_REGEX = "(.+)=(.*)"; + + private IntentUtils() {} + + /** + * Returns a list of environment variables and their values. These are parsed from an Intent extra + * with the key -> value format: + * env# -> ENV_VAR=VALUE + * + * # in env# is expected to be increasing from 0. + * + * @return A Map of environment variable name to value, e.g. ENV_VAR -> VALUE + */ + public static HashMap<String, String> getEnvVarMap(@NonNull final SafeIntent intent) { + // Optimization: get matcher for re-use. Pattern.matcher creates a new object every time so it'd be great + // to avoid the unnecessary allocation, particularly because we expect to be called on the startup path. + final Pattern envVarPattern = Pattern.compile(ENV_VAR_REGEX); + final Matcher matcher = envVarPattern.matcher(""); // argument does not matter here. + + // This is expected to be an external intent so we should use SafeIntent to prevent crashing. + final HashMap<String, String> out = new HashMap<>(); + int i = 0; + while (true) { + final String envKey = "env" + i; + i += 1; + if (!intent.hasExtra(envKey)) { + break; + } + + maybeAddEnvVarToEnvVarMap(out, intent, envKey, matcher); + } + return out; + } + + /** + * @param envVarMap the map to add the env var to + * @param intent the intent from which to extract the env var + * @param envKey the key at which the env var resides + * @param envVarMatcher a matcher initialized with the env var pattern to extract + */ + private static void maybeAddEnvVarToEnvVarMap(@NonNull final HashMap<String, String> envVarMap, + @NonNull final SafeIntent intent, @NonNull final String envKey, @NonNull final Matcher envVarMatcher) { + final String envValue = intent.getStringExtra(envKey); + if (envValue == null) { + return; // nothing to do here! + } + + envVarMatcher.reset(envValue); + if (envVarMatcher.matches()) { + final String envVarName = envVarMatcher.group(1); + final String envVarValue = envVarMatcher.group(2); + envVarMap.put(envVarName, envVarValue); + } + } + + public static Bundle getBundleExtraSafe(final Intent intent, final String name) { + return new SafeIntent(intent).getBundleExtra(name); + } + + public static String getStringExtraSafe(final Intent intent, final String name) { + return new SafeIntent(intent).getStringExtra(name); + } + + public static boolean getBooleanExtraSafe(final Intent intent, final String name, final boolean defaultValue) { + return new SafeIntent(intent).getBooleanExtra(name, defaultValue); + } + + /** + * Gets whether or not we're in automation from the passed in environment variables. + * + * We need to read environment variables from the intent string + * extra because environment variables from our test harness aren't set + * until Gecko is loaded, and we need to know this before then. + * + * The return value of this method should be used early since other + * initialization may depend on its results. + */ + @CheckResult + public static boolean getIsInAutomationFromEnvironment(final SafeIntent intent) { + final HashMap<String, String> envVars = IntentUtils.getEnvVarMap(intent); + return !TextUtils.isEmpty(envVars.get(IntentUtils.ENV_VAR_IN_AUTOMATION)); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/JSONUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/JSONUtils.java new file mode 100644 index 000000000..4ec98ec9e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/JSONUtils.java @@ -0,0 +1,69 @@ +/* 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.util; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import android.os.Bundle; +import android.util.Log; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +public final class JSONUtils { + private static final String LOGTAG = "GeckoJSONUtils"; + + private JSONUtils() {} + + public static UUID getUUID(String name, JSONObject json) { + String uuid = json.optString(name, null); + return (uuid != null) ? UUID.fromString(uuid) : null; + } + + public static void putUUID(String name, UUID uuid, JSONObject json) { + String uuidString = uuid.toString(); + try { + json.put(name, uuidString); + } catch (JSONException e) { + throw new IllegalArgumentException(name + "=" + uuidString, e); + } + } + + public static JSONObject bundleToJSON(Bundle bundle) { + if (bundle == null || bundle.isEmpty()) { + return null; + } + + JSONObject json = new JSONObject(); + for (String key : bundle.keySet()) { + try { + json.put(key, bundle.get(key)); + } catch (JSONException e) { + Log.w(LOGTAG, "Error building JSON response.", e); + } + } + + return json; + } + + // Handles conversions between a JSONArray and a Set<String> + public static Set<String> parseStringSet(JSONArray json) { + final Set<String> ret = new HashSet<String>(); + + for (int i = 0; i < json.length(); i++) { + try { + ret.add(json.getString(i)); + } catch (JSONException ex) { + Log.i(LOGTAG, "Error parsing json", ex); + } + } + + return ret; + } + +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/MenuUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/MenuUtils.java new file mode 100644 index 000000000..e44fdd541 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/MenuUtils.java @@ -0,0 +1,33 @@ +/* -*- 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.util; + +import android.view.Menu; +import android.view.MenuItem; + +public class MenuUtils { + /* + * This method looks for a menuitem and sets it's visible state, if + * it exists. + */ + public static void safeSetVisible(Menu menu, int id, boolean visible) { + MenuItem item = menu.findItem(id); + if (item != null) { + item.setVisible(visible); + } + } + + /* + * This method looks for a menuitem and sets it's enabled state, if + * it exists. + */ + public static void safeSetEnabled(Menu menu, int id, boolean enabled) { + MenuItem item = menu.findItem(id); + if (item != null) { + item.setEnabled(enabled); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeEventListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeEventListener.java new file mode 100644 index 000000000..2a1b6e89a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeEventListener.java @@ -0,0 +1,23 @@ +/* -*- 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.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +@RobocopTarget +public interface NativeEventListener { + /** + * Handles a message sent from Gecko. + * + * @param event The name of the event being sent. + * @param message The message data. + * @param callback The callback interface for this message. A callback is provided only if the + * originating Messaging.sendRequest call included a callback argument; otherwise, + * callback will be null. All listeners for a given event are given the same + * callback object, and exactly one listener must handle the callback. + */ + void handleMessage(String event, NativeJSObject message, EventCallback callback); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSContainer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSContainer.java new file mode 100644 index 000000000..daefe6de0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSContainer.java @@ -0,0 +1,37 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * NativeJSContainer is a wrapper around the SpiderMonkey JSAPI to make it possible to + * access Javascript objects in Java. + * + * A container must only be used on the thread it is attached to. To use it on another + * thread, call {@link #clone()} to make a copy, and use the copy on the other thread. + * When a copy is first used, it becomes attached to the thread using it. + */ +@WrapForJNI(calledFrom = "gecko") +public final class NativeJSContainer extends NativeJSObject +{ + private NativeJSContainer() { + } + + /** + * Make a copy of this container for use by another thread. When the copy is first used, + * it becomes attached to the thread using it. + */ + @Override + public native NativeJSContainer clone(); + + /** + * Dispose all associated native objects. Subsequent use of any objects derived from + * this container will throw a NullPointerException. + */ + @Override + public native void disposeNative(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSObject.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSObject.java new file mode 100644 index 000000000..0d1f0a037 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NativeJSObject.java @@ -0,0 +1,533 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +import android.os.Bundle; + +/** + * NativeJSObject is a wrapper around the SpiderMonkey JSAPI to make it possible to + * access Javascript objects in Java. + */ +@WrapForJNI(calledFrom = "gecko") +public class NativeJSObject extends JNIObject +{ + @SuppressWarnings("serial") + @JNITarget + public static final class InvalidPropertyException extends RuntimeException { + public InvalidPropertyException(final String msg) { + super(msg); + } + } + + protected NativeJSObject() { + } + + @Override + protected void disposeNative() { + // NativeJSObject is disposed as part of NativeJSContainer disposal. + } + + /** + * Returns the value of a boolean property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native boolean getBoolean(String name); + + /** + * Returns the value of a boolean property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native boolean optBoolean(String name, boolean fallback); + + /** + * Returns the value of a boolean array property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native boolean[] getBooleanArray(String name); + + /** + * Returns the value of a boolean array property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native boolean[] optBooleanArray(String name, boolean[] fallback); + + /** + * Returns the value of an object property as a Bundle. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native Bundle getBundle(String name); + + /** + * Returns the value of an object property as a Bundle. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native Bundle optBundle(String name, Bundle fallback); + + /** + * Returns the value of an object array property as a Bundle array. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native Bundle[] getBundleArray(String name); + + /** + * Returns the value of an object array property as a Bundle array. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native Bundle[] optBundleArray(String name, Bundle[] fallback); + + /** + * Returns the value of a double property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native double getDouble(String name); + + /** + * Returns the value of a double property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native double optDouble(String name, double fallback); + + /** + * Returns the value of a double array property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native double[] getDoubleArray(String name); + + /** + * Returns the value of a double array property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native double[] optDoubleArray(String name, double[] fallback); + + /** + * Returns the value of an int property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native int getInt(String name); + + /** + * Returns the value of an int property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native int optInt(String name, int fallback); + + /** + * Returns the value of an int array property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native int[] getIntArray(String name); + + /** + * Returns the value of an int array property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native int[] optIntArray(String name, int[] fallback); + + /** + * Returns the value of an object property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native NativeJSObject getObject(String name); + + /** + * Returns the value of an object property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native NativeJSObject optObject(String name, NativeJSObject fallback); + + /** + * Returns the value of an object array property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native NativeJSObject[] getObjectArray(String name); + + /** + * Returns the value of an object array property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native NativeJSObject[] optObjectArray(String name, NativeJSObject[] fallback); + + /** + * Returns the value of a string property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native String getString(String name); + + /** + * Returns the value of a string property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native String optString(String name, String fallback); + + /** + * Returns the value of a string array property. + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property does not exist or if its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native String[] getStringArray(String name); + + /** + * Returns the value of a string array property. + * + * @param name + * Property name + * @param fallback + * Value to return if property does not exist + * @throws IllegalArgumentException + * If name is null + * @throws InvalidPropertyException + * If the property exists and its type does not match the return type + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native String[] optStringArray(String name, String[] fallback); + + /** + * Returns whether a property exists in this object + * + * @param name + * Property name + * @throws IllegalArgumentException + * If name is null + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native boolean has(String name); + + /** + * Returns the Bundle representation of this object. + * + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + public native Bundle toBundle(); + + /** + * Returns the JSON representation of this object. + * + * @throws NullPointerException + * If this JS object has been disposed + * @throws IllegalThreadStateException + * If not called on the thread this object is attached to + * @throws UnsupportedOperationException + * If an internal JSAPI call failed + */ + @Override + public native String toString(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java new file mode 100644 index 000000000..2210e43ed --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java @@ -0,0 +1,177 @@ +/* -*- 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.util; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.support.annotation.Nullable; +import android.support.annotation.NonNull; +import android.telephony.TelephonyManager; + +public class NetworkUtils { + /* + * Keep the below constants in sync with + * http://dxr.mozilla.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public enum ConnectionSubType { + CELL_2G("2g"), + CELL_3G("3g"), + CELL_4G("4g"), + ETHERNET("ethernet"), + WIFI("wifi"), + WIMAX("wimax"), + UNKNOWN("unknown"); + + public final String value; + ConnectionSubType(String value) { + this.value = value; + } + } + + /* + * Keep the below constants in sync with + * http://dxr.mozilla.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public enum NetworkStatus { + UP("up"), + DOWN("down"), + UNKNOWN("unknown"); + + public final String value; + + NetworkStatus(String value) { + this.value = value; + } + } + + // Connection Type defined in Network Information API v3. + // See Bug 1270401 - current W3C Spec (Editor's Draft) is different, it also contains wimax, mixed, unknown. + // W3C spec: http://w3c.github.io/netinfo/#the-connectiontype-enum + public enum ConnectionType { + CELLULAR(0), + BLUETOOTH(1), + ETHERNET(2), + WIFI(3), + OTHER(4), + NONE(5); + + public final int value; + + ConnectionType(int value) { + this.value = value; + } + } + + /** + * Indicates whether network connectivity exists and it is possible to establish connections and pass data. + */ + public static boolean isConnected(@NonNull Context context) { + return isConnected((ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE)); + } + + public static boolean isConnected(ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return false; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + return networkInfo != null && networkInfo.isConnected(); + } + + /** + * For mobile connections, maps particular connection subtype to a general 2G, 3G, 4G bucket. + */ + public static ConnectionSubType getConnectionSubType(ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return ConnectionSubType.UNKNOWN; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + + if (networkInfo == null) { + return ConnectionSubType.UNKNOWN; + } + + switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_ETHERNET: + return ConnectionSubType.ETHERNET; + case ConnectivityManager.TYPE_MOBILE: + return getGenericMobileSubtype(networkInfo.getSubtype()); + case ConnectivityManager.TYPE_WIMAX: + return ConnectionSubType.WIMAX; + case ConnectivityManager.TYPE_WIFI: + return ConnectionSubType.WIFI; + default: + return ConnectionSubType.UNKNOWN; + } + } + + public static ConnectionType getConnectionType(ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return ConnectionType.NONE; + } + + final NetworkInfo networkInfo = connectivityManager.getActiveNetworkInfo(); + if (networkInfo == null) { + return ConnectionType.NONE; + } + + switch (networkInfo.getType()) { + case ConnectivityManager.TYPE_BLUETOOTH: + return ConnectionType.BLUETOOTH; + case ConnectivityManager.TYPE_ETHERNET: + return ConnectionType.ETHERNET; + // Fallthrough, MOBILE and WIMAX both map to CELLULAR. + case ConnectivityManager.TYPE_MOBILE: + case ConnectivityManager.TYPE_WIMAX: + return ConnectionType.CELLULAR; + case ConnectivityManager.TYPE_WIFI: + return ConnectionType.WIFI; + default: + return ConnectionType.OTHER; + } + } + + public static NetworkStatus getNetworkStatus(ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return NetworkStatus.UNKNOWN; + } + + if (isConnected(connectivityManager)) { + return NetworkStatus.UP; + } + return NetworkStatus.DOWN; + } + + private static ConnectionSubType getGenericMobileSubtype(int subtype) { + switch (subtype) { + // 2G types: fallthrough 5x + case TelephonyManager.NETWORK_TYPE_GPRS: + case TelephonyManager.NETWORK_TYPE_EDGE: + case TelephonyManager.NETWORK_TYPE_CDMA: + case TelephonyManager.NETWORK_TYPE_1xRTT: + case TelephonyManager.NETWORK_TYPE_IDEN: + return ConnectionSubType.CELL_2G; + // 3G types: fallthrough 9x + case TelephonyManager.NETWORK_TYPE_UMTS: + case TelephonyManager.NETWORK_TYPE_EVDO_0: + case TelephonyManager.NETWORK_TYPE_EVDO_A: + case TelephonyManager.NETWORK_TYPE_HSDPA: + case TelephonyManager.NETWORK_TYPE_HSUPA: + case TelephonyManager.NETWORK_TYPE_HSPA: + case TelephonyManager.NETWORK_TYPE_EVDO_B: + case TelephonyManager.NETWORK_TYPE_EHRPD: + case TelephonyManager.NETWORK_TYPE_HSPAP: + return ConnectionSubType.CELL_3G; + // 4G - just one type! + case TelephonyManager.NETWORK_TYPE_LTE: + return ConnectionSubType.CELL_4G; + default: + return ConnectionSubType.UNKNOWN; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NonEvictingLruCache.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NonEvictingLruCache.java new file mode 100644 index 000000000..793b39b81 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NonEvictingLruCache.java @@ -0,0 +1,44 @@ +/* -*- 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.util; + +import android.util.LruCache; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * An LruCache that also supports a set of items that will never be evicted. + * + * Alas, LruCache is final, so we compose rather than inherit. + */ +public class NonEvictingLruCache<K, V> { + private final ConcurrentHashMap<K, V> permanent = new ConcurrentHashMap<K, V>(); + private final LruCache<K, V> evictable; + + public NonEvictingLruCache(final int evictableSize) { + evictable = new LruCache<K, V>(evictableSize); + } + + public V get(K key) { + V val = permanent.get(key); + if (val == null) { + return evictable.get(key); + } + return val; + } + + public void putWithoutEviction(K key, V value) { + permanent.put(key, value); + } + + public void put(K key, V value) { + evictable.put(key, value); + } + + public void evictAll() { + evictable.evictAll(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/PrefUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/PrefUtils.java new file mode 100644 index 000000000..217e40b91 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/PrefUtils.java @@ -0,0 +1,70 @@ +/* -*- 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.util; + +import java.util.HashSet; +import java.util.Set; + +import org.json.JSONArray; +import org.json.JSONException; +import org.mozilla.gecko.AppConstants.Versions; + +import android.content.SharedPreferences; +import android.util.Log; + + +public class PrefUtils { + private static final String LOGTAG = "GeckoPrefUtils"; + + // Cross version compatible way to get a string set from a pref + public static Set<String> getStringSet(final SharedPreferences prefs, + final String key, + final Set<String> defaultVal) { + if (!prefs.contains(key)) { + return defaultVal; + } + + // If this is Android version >= 11, try to use a Set<String>. + try { + return prefs.getStringSet(key, new HashSet<String>()); + } catch (ClassCastException ex) { + // A ClassCastException means we've upgraded from a pre-v11 Android to a new one + final Set<String> val = getFromJSON(prefs, key); + SharedPreferences.Editor edit = prefs.edit(); + putStringSet(edit, key, val).apply(); + return val; + } + } + + private static Set<String> getFromJSON(SharedPreferences prefs, String key) { + try { + final String val = prefs.getString(key, "[]"); + return JSONUtils.parseStringSet(new JSONArray(val)); + } catch (JSONException ex) { + Log.i(LOGTAG, "Unable to parse JSON", ex); + } + + return new HashSet<String>(); + } + + /** + * Cross version compatible way to save a set of strings. + * <p> + * This method <b>does not commit</b> any transaction. It is up to callers + * to commit. + * + * @param editor to write to. + * @param key to write. + * @param vals comprising string set. + * @return + */ + public static SharedPreferences.Editor putStringSet(final SharedPreferences.Editor editor, + final String key, + final Set<String> vals) { + editor.putStringSet(key, vals); + return editor; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java new file mode 100644 index 000000000..35010242b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java @@ -0,0 +1,155 @@ +/* Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This code is based on AOSP /libcore/luni/src/main/java/java/net/ProxySelectorImpl.java + +package org.mozilla.gecko.util; + +import android.support.annotation.Nullable; +import android.text.TextUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URI; +import java.net.URLConnection; +import java.util.List; + +public class ProxySelector { + public static URLConnection openConnectionWithProxy(URI uri) throws IOException { + java.net.ProxySelector ps = java.net.ProxySelector.getDefault(); + Proxy proxy = Proxy.NO_PROXY; + if (ps != null) { + List<Proxy> proxies = ps.select(uri); + if (proxies != null && !proxies.isEmpty()) { + proxy = proxies.get(0); + } + } + + return uri.toURL().openConnection(proxy); + } + + public ProxySelector() { + } + + public Proxy select(String scheme, String host) { + int port = -1; + Proxy proxy = null; + String nonProxyHostsKey = null; + boolean httpProxyOkay = true; + if ("http".equalsIgnoreCase(scheme)) { + port = 80; + nonProxyHostsKey = "http.nonProxyHosts"; + proxy = lookupProxy("http.proxyHost", "http.proxyPort", Proxy.Type.HTTP, port); + } else if ("https".equalsIgnoreCase(scheme)) { + port = 443; + nonProxyHostsKey = "https.nonProxyHosts"; // RI doesn't support this + proxy = lookupProxy("https.proxyHost", "https.proxyPort", Proxy.Type.HTTP, port); + } else if ("ftp".equalsIgnoreCase(scheme)) { + port = 80; // not 21 as you might guess + nonProxyHostsKey = "ftp.nonProxyHosts"; + proxy = lookupProxy("ftp.proxyHost", "ftp.proxyPort", Proxy.Type.HTTP, port); + } else if ("socket".equalsIgnoreCase(scheme)) { + httpProxyOkay = false; + } else { + return Proxy.NO_PROXY; + } + + if (nonProxyHostsKey != null + && isNonProxyHost(host, System.getProperty(nonProxyHostsKey))) { + return Proxy.NO_PROXY; + } + + if (proxy != null) { + return proxy; + } + + if (httpProxyOkay) { + proxy = lookupProxy("proxyHost", "proxyPort", Proxy.Type.HTTP, port); + if (proxy != null) { + return proxy; + } + } + + proxy = lookupProxy("socksProxyHost", "socksProxyPort", Proxy.Type.SOCKS, 1080); + if (proxy != null) { + return proxy; + } + + return Proxy.NO_PROXY; + } + + /** + * Returns the proxy identified by the {@code hostKey} system property, or + * null. + */ + @Nullable + private Proxy lookupProxy(String hostKey, String portKey, Proxy.Type type, int defaultPort) { + final String host = System.getProperty(hostKey); + if (TextUtils.isEmpty(host)) { + return null; + } + + final int port = getSystemPropertyInt(portKey, defaultPort); + if (port == -1) { + // Port can be -1. See bug 1270529. + return null; + } + + return new Proxy(type, InetSocketAddress.createUnresolved(host, port)); + } + + private int getSystemPropertyInt(String key, int defaultValue) { + String string = System.getProperty(key); + if (string != null) { + try { + return Integer.parseInt(string); + } catch (NumberFormatException ignored) { + } + } + return defaultValue; + } + + /** + * Returns true if the {@code nonProxyHosts} system property pattern exists + * and matches {@code host}. + */ + private boolean isNonProxyHost(String host, String nonProxyHosts) { + if (host == null || nonProxyHosts == null) { + return false; + } + + // construct pattern + StringBuilder patternBuilder = new StringBuilder(); + for (int i = 0; i < nonProxyHosts.length(); i++) { + char c = nonProxyHosts.charAt(i); + switch (c) { + case '.': + patternBuilder.append("\\."); + break; + case '*': + patternBuilder.append(".*"); + break; + default: + patternBuilder.append(c); + } + } + // check whether the host is the nonProxyHosts. + String pattern = patternBuilder.toString(); + return host.matches(pattern); + } +} + diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java new file mode 100644 index 000000000..5bcad1c60 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/RawResource.java @@ -0,0 +1,52 @@ +/* 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.util; + +import android.content.Context; +import android.content.res.Resources; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringWriter; + +/** + * {@code RawResource} provides API to load raw resources in different + * forms. For now, we only load them as strings. We're using raw resources + * as localizable 'assets' as opposed to a string that can be directly + * translatable e.g. JSON file vs string. + * + * This is just a utility class to avoid code duplication for the different + * cases where need to read such assets. + */ +public final class RawResource { + public static String getAsString(Context context, int id) throws IOException { + InputStreamReader reader = null; + + try { + final Resources res = context.getResources(); + final InputStream is = res.openRawResource(id); + if (is == null) { + return null; + } + + reader = new InputStreamReader(is); + + final char[] buffer = new char[1024]; + final StringWriter s = new StringWriter(); + + int n; + while ((n = reader.read(buffer, 0, buffer.length)) != -1) { + s.write(buffer, 0, n); + } + + return s.toString(); + } finally { + if (reader != null) { + reader.close(); + } + } + } +}
\ No newline at end of file diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java new file mode 100644 index 000000000..308168f43 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/StringUtils.java @@ -0,0 +1,293 @@ +/* -*- 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.util; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.text.TextUtils; + +import org.mozilla.gecko.AppConstants.Versions; + +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public class StringUtils { + private static final String LOGTAG = "GeckoStringUtils"; + + private static final String FILTER_URL_PREFIX = "filter://"; + private static final String USER_ENTERED_URL_PREFIX = "user-entered:"; + + /* + * This method tries to guess if the given string could be a search query or URL, + * and returns a previous result if there is ambiguity + * + * Search examples: + * foo + * foo bar.com + * foo http://bar.com + * + * URL examples + * foo.com + * foo.c + * :foo + * http://foo.com bar + * + * wasSearchQuery specifies whether text was a search query before the latest change + * in text. In ambiguous cases where the new text can be either a search or a URL, + * wasSearchQuery is returned + */ + public static boolean isSearchQuery(String text, boolean wasSearchQuery) { + // We remove leading and trailing white spaces when decoding URLs + text = text.trim(); + if (text.length() == 0) + return wasSearchQuery; + + int colon = text.indexOf(':'); + int dot = text.indexOf('.'); + int space = text.indexOf(' '); + + // If a space is found before any dot and colon, we assume this is a search query + if (space > -1 && (colon == -1 || space < colon) && (dot == -1 || space < dot)) { + return true; + } + // Otherwise, if a dot or a colon is found, we assume this is a URL + if (dot > -1 || colon > -1) { + return false; + } + // Otherwise, text is ambiguous, and we keep its status unchanged + return wasSearchQuery; + } + + /** + * Strip the ref from a URL, if present + * + * @return The base URL, without the ref. The original String is returned if it has no ref, + * of if the input is malformed. + */ + public static String stripRef(final String inputURL) { + if (inputURL == null) { + return null; + } + + final int refIndex = inputURL.indexOf('#'); + + if (refIndex >= 0) { + return inputURL.substring(0, refIndex); + } + + return inputURL; + } + + public static class UrlFlags { + public static final int NONE = 0; + public static final int STRIP_HTTPS = 1; + } + + public static String stripScheme(String url) { + return stripScheme(url, UrlFlags.NONE); + } + + public static String stripScheme(String url, int flags) { + if (url == null) { + return url; + } + + String newURL = url; + + if (newURL.startsWith("http://")) { + newURL = newURL.replace("http://", ""); + } else if (newURL.startsWith("https://") && flags == UrlFlags.STRIP_HTTPS) { + newURL = newURL.replace("https://", ""); + } + + if (newURL.endsWith("/")) { + newURL = newURL.substring(0, newURL.length()-1); + } + + return newURL; + } + + public static boolean isHttpOrHttps(String url) { + if (TextUtils.isEmpty(url)) { + return false; + } + + return url.startsWith("http://") || url.startsWith("https://"); + } + + public static String stripCommonSubdomains(String host) { + if (host == null) { + return host; + } + + // In contrast to desktop, we also strip mobile subdomains, + // since its unlikely users are intentionally typing them + int start = 0; + + if (host.startsWith("www.")) { + start = 4; + } else if (host.startsWith("mobile.")) { + start = 7; + } else if (host.startsWith("m.")) { + start = 2; + } + + return host.substring(start); + } + + /** + * Searches the url query string for the first value with the given key. + */ + public static String getQueryParameter(String url, String desiredKey) { + if (TextUtils.isEmpty(url) || TextUtils.isEmpty(desiredKey)) { + return null; + } + + final String[] urlParts = url.split("\\?"); + if (urlParts.length < 2) { + return null; + } + + final String query = urlParts[1]; + for (final String param : query.split("&")) { + final String pair[] = param.split("="); + final String key = Uri.decode(pair[0]); + + // Key is empty or does not match the key we're looking for, discard + if (TextUtils.isEmpty(key) || !key.equals(desiredKey)) { + continue; + } + // No value associated with key, discard + if (pair.length < 2) { + continue; + } + final String value = Uri.decode(pair[1]); + if (TextUtils.isEmpty(value)) { + return null; + } + return value; + } + + return null; + } + + public static boolean isFilterUrl(String url) { + if (TextUtils.isEmpty(url)) { + return false; + } + + return url.startsWith(FILTER_URL_PREFIX); + } + + public static String getFilterFromUrl(String url) { + if (TextUtils.isEmpty(url)) { + return null; + } + + return url.substring(FILTER_URL_PREFIX.length()); + } + + public static boolean isShareableUrl(final String url) { + final String scheme = Uri.parse(url).getScheme(); + return !("about".equals(scheme) || "chrome".equals(scheme) || + "file".equals(scheme) || "resource".equals(scheme)); + } + + public static boolean isUserEnteredUrl(String url) { + return (url != null && url.startsWith(USER_ENTERED_URL_PREFIX)); + } + + /** + * Given a url with a user-entered scheme, extract the + * scheme-specific component. For e.g, given "user-entered://www.google.com", + * this method returns "//www.google.com". If the passed url + * does not have a user-entered scheme, the same url will be returned. + * + * @param url to be decoded + * @return url component entered by user + */ + public static String decodeUserEnteredUrl(String url) { + Uri uri = Uri.parse(url); + if ("user-entered".equals(uri.getScheme())) { + return uri.getSchemeSpecificPart(); + } + return url; + } + + public static String encodeUserEnteredUrl(String url) { + return Uri.fromParts("user-entered", url, null).toString(); + } + + /** + * Compatibility layer for API < 11. + * + * Returns a set of the unique names of all query parameters. Iterating + * over the set will return the names in order of their first occurrence. + * + * @param uri + * @throws UnsupportedOperationException if this isn't a hierarchical URI + * + * @return a set of decoded names + */ + public static Set<String> getQueryParameterNames(Uri uri) { + return uri.getQueryParameterNames(); + } + + public static String safeSubstring(@NonNull final String str, final int start, final int end) { + return str.substring( + Math.max(0, start), + Math.min(end, str.length())); + } + + /** + * Check if this might be a RTL (right-to-left) text by looking at the first character. + */ + public static boolean isRTL(String text) { + if (TextUtils.isEmpty(text)) { + return false; + } + + final char character = text.charAt(0); + final byte directionality = Character.getDirectionality(character); + + return directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT + || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC + || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING + || directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE; + } + + /** + * Force LTR (left-to-right) by prepending the text with the "left-to-right mark" (U+200E) if needed. + */ + public static String forceLTR(String text) { + if (!isRTL(text)) { + return text; + } + + return "\u200E" + text; + } + + /** + * Joining together a sequence of strings with a separator. + */ + public static String join(@NonNull String separator, @NonNull List<String> parts) { + if (parts.size() == 0) { + return ""; + } + + final StringBuilder builder = new StringBuilder(); + builder.append(parts.get(0)); + + for (int i = 1; i < parts.size(); i++) { + builder.append(separator); + builder.append(parts.get(i)); + } + + return builder.toString(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java new file mode 100644 index 000000000..884a56dc4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java @@ -0,0 +1,247 @@ +/* -*- 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.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +import java.util.Map; + +import android.os.Handler; +import android.os.Looper; +import android.os.MessageQueue; +import android.util.Log; + +public final class ThreadUtils { + private static final String LOGTAG = "ThreadUtils"; + + /** + * Controls the action taken when a method like + * {@link ThreadUtils#assertOnUiThread(AssertBehavior)} detects a problem. + */ + public static enum AssertBehavior { + NONE, + THROW, + } + + private static final Thread sUiThread = Looper.getMainLooper().getThread(); + private static final Handler sUiHandler = new Handler(Looper.getMainLooper()); + + private static volatile Thread sBackgroundThread; + + // Referenced directly from GeckoAppShell in highly performance-sensitive code (The extra + // function call of the getter was harming performance. (Bug 897123)) + // Once Bug 709230 is resolved we should reconsider this as ProGuard should be able to optimise + // this out at compile time. + public static Handler sGeckoHandler; + public static volatile Thread sGeckoThread; + + // Delayed Runnable that resets the Gecko thread priority. + private static final Runnable sPriorityResetRunnable = new Runnable() { + @Override + public void run() { + resetGeckoPriority(); + } + }; + + private static boolean sIsGeckoPriorityReduced; + + @SuppressWarnings("serial") + public static class UiThreadBlockedException extends RuntimeException { + public UiThreadBlockedException() { + super(); + } + + public UiThreadBlockedException(String msg) { + super(msg); + } + + public UiThreadBlockedException(String msg, Throwable e) { + super(msg, e); + } + + public UiThreadBlockedException(Throwable e) { + super(e); + } + } + + public static void dumpAllStackTraces() { + Log.w(LOGTAG, "Dumping ALL the threads!"); + Map<Thread, StackTraceElement[]> allStacks = Thread.getAllStackTraces(); + for (Thread t : allStacks.keySet()) { + Log.w(LOGTAG, t.toString()); + for (StackTraceElement ste : allStacks.get(t)) { + Log.w(LOGTAG, ste.toString()); + } + Log.w(LOGTAG, "----"); + } + } + + public static void setBackgroundThread(Thread thread) { + sBackgroundThread = thread; + } + + public static Thread getUiThread() { + return sUiThread; + } + + public static Handler getUiHandler() { + return sUiHandler; + } + + public static void postToUiThread(Runnable runnable) { + sUiHandler.post(runnable); + } + + public static void postDelayedToUiThread(Runnable runnable, long timeout) { + sUiHandler.postDelayed(runnable, timeout); + } + + public static void removeCallbacksFromUiThread(Runnable runnable) { + sUiHandler.removeCallbacks(runnable); + } + + public static Thread getBackgroundThread() { + return sBackgroundThread; + } + + public static Handler getBackgroundHandler() { + return GeckoBackgroundThread.getHandler(); + } + + public static void postToBackgroundThread(Runnable runnable) { + GeckoBackgroundThread.post(runnable); + } + + public static void assertOnUiThread(final AssertBehavior assertBehavior) { + assertOnThread(getUiThread(), assertBehavior); + } + + public static void assertOnUiThread() { + assertOnThread(getUiThread(), AssertBehavior.THROW); + } + + public static void assertNotOnUiThread() { + assertNotOnThread(getUiThread(), AssertBehavior.THROW); + } + + @RobocopTarget + public static void assertOnGeckoThread() { + assertOnThread(sGeckoThread, AssertBehavior.THROW); + } + + public static void assertNotOnGeckoThread() { + if (sGeckoThread == null) { + // Cannot be on Gecko thread if Gecko thread is not live yet. + return; + } + assertNotOnThread(sGeckoThread, AssertBehavior.THROW); + } + + public static void assertOnBackgroundThread() { + assertOnThread(getBackgroundThread(), AssertBehavior.THROW); + } + + public static void assertOnThread(final Thread expectedThread) { + assertOnThread(expectedThread, AssertBehavior.THROW); + } + + public static void assertOnThread(final Thread expectedThread, AssertBehavior behavior) { + assertOnThreadComparison(expectedThread, behavior, true); + } + + public static void assertNotOnThread(final Thread expectedThread, AssertBehavior behavior) { + assertOnThreadComparison(expectedThread, behavior, false); + } + + private static void assertOnThreadComparison(final Thread expectedThread, AssertBehavior behavior, boolean expected) { + final Thread currentThread = Thread.currentThread(); + final long currentThreadId = currentThread.getId(); + final long expectedThreadId = expectedThread.getId(); + + if ((currentThreadId == expectedThreadId) == expected) { + return; + } + + final String message; + if (expected) { + message = "Expected thread " + expectedThreadId + + " (\"" + expectedThread.getName() + "\"), but running on thread " + + currentThreadId + " (\"" + currentThread.getName() + "\")"; + } else { + message = "Expected anything but " + expectedThreadId + + " (\"" + expectedThread.getName() + "\"), but running there."; + } + + final IllegalThreadStateException e = new IllegalThreadStateException(message); + + switch (behavior) { + case THROW: + throw e; + default: + Log.e(LOGTAG, "Method called on wrong thread!", e); + } + } + + public static boolean isOnGeckoThread() { + if (sGeckoThread != null) { + return isOnThread(sGeckoThread); + } + return false; + } + + public static boolean isOnUiThread() { + return isOnThread(getUiThread()); + } + + @RobocopTarget + public static boolean isOnBackgroundThread() { + if (sBackgroundThread == null) { + return false; + } + + return isOnThread(sBackgroundThread); + } + + @RobocopTarget + public static boolean isOnThread(Thread thread) { + return (Thread.currentThread().getId() == thread.getId()); + } + + /** + * Reduces the priority of the Gecko thread, allowing other operations + * (such as those related to the UI and database) to take precedence. + * + * Note that there are no guards in place to prevent multiple calls + * to this method from conflicting with each other. + * + * @param timeout Timeout in ms after which the priority will be reset + */ + public static void reduceGeckoPriority(long timeout) { + if (Runtime.getRuntime().availableProcessors() > 1) { + // Don't reduce priority for multicore devices. We use availableProcessors() + // for its fast performance. It may give false negatives (i.e. multicore + // detected as single-core), but we can tolerate this behavior. + return; + } + if (!sIsGeckoPriorityReduced && sGeckoThread != null) { + sIsGeckoPriorityReduced = true; + sGeckoThread.setPriority(Thread.MIN_PRIORITY); + getUiHandler().postDelayed(sPriorityResetRunnable, timeout); + } + } + + /** + * Resets the priority of a thread whose priority has been reduced + * by reduceGeckoPriority. + */ + public static void resetGeckoPriority() { + if (sIsGeckoPriorityReduced) { + sIsGeckoPriorityReduced = false; + sGeckoThread.setPriority(Thread.NORM_PRIORITY); + getUiHandler().removeCallbacks(sPriorityResetRunnable); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UIAsyncTask.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UIAsyncTask.java new file mode 100644 index 000000000..26cc32a99 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UIAsyncTask.java @@ -0,0 +1,121 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.util; + +import android.os.Handler; +import android.os.Looper; + +/** + * Executes a background task and publishes the result on the UI thread. + * + * The standard {@link android.os.AsyncTask} only runs onPostExecute on the + * thread it is constructed on, so this is a convenience class for creating + * tasks off the UI thread. + * + * We use generics differently to Android's AsyncTask. + * Android uses a "Params" type parameter to represent the type of all the parameters to this task. + * It then uses arguments of type Params... to permit arbitrarily-many of these to be passed + * fluently. + * + * Unfortunately, since Java does not support generic array types (and since varargs desugars to a + * single array parameter) that behaviour exposes a hole in the type system. See: + * http://docs.oracle.com/javase/tutorial/java/generics/nonReifiableVarargsType.html#vulnerabilities + * + * Instead, we equivalently have a single type parameter "Param". A UiAsyncTask may take exactly one + * parameter of type Param. Since Param can be an array type, this no more restrictive than the + * other approach, it just provides additional type safety. + */ +public abstract class UIAsyncTask<Param, Result> { + /** + * Provide a convenient API for parameter-free UiAsyncTasks by wrapping parameter-taking methods + * from UiAsyncTask in parameterless equivalents. + */ + public static abstract class WithoutParams<InnerResult> extends UIAsyncTask<Void, InnerResult> { + public WithoutParams(Handler backgroundThreadHandler) { + super(backgroundThreadHandler); + } + + public void execute() { + execute(null); + } + + @Override + protected InnerResult doInBackground(Void unused) { + return doInBackground(); + } + + protected abstract InnerResult doInBackground(); + } + + final Handler mBackgroundThreadHandler; + private volatile boolean mCancelled; + private static Handler sHandler; + + /** + * Creates a new asynchronous task. + * + * @param backgroundThreadHandler the handler to execute the background task on + */ + public UIAsyncTask(Handler backgroundThreadHandler) { + mBackgroundThreadHandler = backgroundThreadHandler; + } + + private static synchronized Handler getUiHandler() { + if (sHandler == null) { + sHandler = new Handler(Looper.getMainLooper()); + } + + return sHandler; + } + + private final class BackgroundTaskRunnable implements Runnable { + private final Param mParam; + + public BackgroundTaskRunnable(Param param) { + mParam = param; + } + + @Override + public void run() { + final Result result = doInBackground(mParam); + + getUiHandler().post(new Runnable() { + @Override + public void run() { + if (mCancelled) { + onCancelled(); + } else { + onPostExecute(result); + } + } + }); + } + } + + protected void execute(final Param param) { + getUiHandler().post(new Runnable() { + @Override + public void run() { + onPreExecute(); + mBackgroundThreadHandler.post(new BackgroundTaskRunnable(param)); + } + }); + } + + public final boolean cancel() { + mCancelled = true; + return mCancelled; + } + + public final boolean isCancelled() { + return mCancelled; + } + + protected void onPreExecute() { } + protected void onPostExecute(Result result) { } + protected void onCancelled() { } + protected abstract Result doInBackground(Param param); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.java new file mode 100644 index 000000000..cef303a87 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/UUIDUtil.java @@ -0,0 +1,19 @@ +/* + * 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.util; + +import java.util.regex.Pattern; + +/** + * Utilities for UUIDs. + */ +public class UUIDUtil { + private UUIDUtil() {} + + public static final String UUID_REGEX = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"; + public static final Pattern UUID_PATTERN = Pattern.compile(UUID_REGEX); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java new file mode 100644 index 000000000..3e8508bce --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WeakReferenceHandler.java @@ -0,0 +1,27 @@ +/* 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.util; + +import android.os.Handler; + +import java.lang.ref.WeakReference; + +/** + * A Handler to help prevent memory leaks when using Handlers as inner classes. + * + * To use, extend the Handler, if it's an inner class, make it static, + * and reference `this` via the associated WeakReference. + * + * For additional context, see the "HandlerLeak" android lint item and this post by Romain Guy: + * https://groups.google.com/forum/#!msg/android-developers/1aPZXZG6kWk/lIYDavGYn5UJ + */ +public class WeakReferenceHandler<T> extends Handler { + public final WeakReference<T> mTarget; + + public WeakReferenceHandler(final T that) { + super(); + mTarget = new WeakReference<>(that); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WindowUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WindowUtils.java new file mode 100644 index 000000000..5298f846a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/WindowUtils.java @@ -0,0 +1,59 @@ +/* -*- 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.util; + +import org.mozilla.gecko.AppConstants.Versions; + +import android.content.Context; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Display; +import android.view.WindowManager; + +import java.lang.reflect.Method; + +public class WindowUtils { + private static final String LOGTAG = "Gecko" + WindowUtils.class.getSimpleName(); + + private WindowUtils() { /* To prevent instantiation */ } + + /** + * Returns the best-guess physical device dimensions, including the system status bars. Note + * that DisplayMetrics.height/widthPixels does not include the system bars. + * + * via http://stackoverflow.com/a/23861333 + * + * @param context the calling Activity's Context + * @return The number of pixels of the device's largest dimension, ignoring software status bars + */ + public static int getLargestDimension(final Context context) { + final Display display = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + + if (Versions.feature17Plus) { + final DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + return Math.max(realMetrics.widthPixels, realMetrics.heightPixels); + + } else { + int tempWidth; + int tempHeight; + try { + final Method getRawH = Display.class.getMethod("getRawHeight"); + final Method getRawW = Display.class.getMethod("getRawWidth"); + tempWidth = (Integer) getRawW.invoke(display); + tempHeight = (Integer) getRawH.invoke(display); + } catch (Exception e) { + // This is the best we can do. + tempWidth = display.getWidth(); + tempHeight = display.getHeight(); + Log.w(LOGTAG, "Couldn't use reflection to get the real display metrics."); + } + + return Math.max(tempWidth, tempHeight); + + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffix.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffix.java new file mode 100644 index 000000000..6a146cfcf --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffix.java @@ -0,0 +1,121 @@ +/* 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.util.publicsuffix; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.WorkerThread; + +import org.mozilla.gecko.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +/** + * Helper methods for the public suffix part of a domain. + * + * A "public suffix" is one under which Internet users can (or historically could) directly register + * names. Some examples of public suffixes are .com, .co.uk and pvt.k12.ma.us. + * + * https://publicsuffix.org/ + * + * Some parts of the implementation of this class are based on InternetDomainName class of the Guava + * project: https://github.com/google/guava + */ +public class PublicSuffix { + /** + * Strip the public suffix from the domain. Returns the original domain if no public suffix + * could be found. + * + * www.mozilla.org -> www.mozilla + * independent.co.uk -> independent + */ + @NonNull + @WorkerThread // This method might need to load data from disk + public static String stripPublicSuffix(Context context, @NonNull String domain) { + if (domain.length() == 0) { + return domain; + } + + final int index = findPublicSuffixIndex(context, domain); + if (index == -1) { + return domain; + } + + return domain.substring(0, index); + } + + /** + * Returns the index of the leftmost part of the public suffix, or -1 if not found. + */ + @WorkerThread + private static int findPublicSuffixIndex(Context context, String domain) { + final List<String> parts = normalizeAndSplit(domain); + final int partsSize = parts.size(); + final Set<String> exact = PublicSuffixPatterns.getExactSet(context); + + for (int i = 0; i < partsSize; i++) { + String ancestorName = StringUtils.join(".", parts.subList(i, partsSize)); + + if (exact.contains(ancestorName)) { + return joinIndex(parts, i); + } + + // Excluded domains (e.g. !nhs.uk) use the next highest + // domain as the effective public suffix (e.g. uk). + if (PublicSuffixPatterns.EXCLUDED.contains(ancestorName)) { + return joinIndex(parts, i + 1); + } + + if (matchesWildcardPublicSuffix(ancestorName)) { + return joinIndex(parts, i); + } + } + + return -1; + } + + /** + * Normalize domain and split into domain parts (www.mozilla.org -> [www, mozilla, org]). + */ + private static List<String> normalizeAndSplit(String domain) { + domain = domain.replaceAll("[.\u3002\uFF0E\uFF61]", "."); // All dot-like characters to '.' + domain = domain.toLowerCase(); + + if (domain.endsWith(".")) { + domain = domain.substring(0, domain.length() - 1); // Strip trailing '.' + } + + List<String> parts = new ArrayList<>(); + Collections.addAll(parts, domain.split("\\.")); + + return parts; + } + + /** + * Translate the index of the leftmost part of the public suffix to the index of the domain string. + * + * [www, mozilla, org] and 2 => 12 (www.mozilla) + */ + private static int joinIndex(List<String> parts, int index) { + int actualIndex = parts.get(0).length(); + + for (int i = 1; i < index; i++) { + actualIndex += parts.get(i).length() + 1; // Add one for the "." that is not part of the list elements + } + + return actualIndex; + } + + /** + * Does the domain name match one of the "wildcard" patterns (e.g. {@code "*.ar"})? + */ + private static boolean matchesWildcardPublicSuffix(String domain) { + final String[] pieces = domain.split("\\.", 2); + return pieces.length == 2 && PublicSuffixPatterns.UNDER.contains(pieces[1]); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffixPatterns.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffixPatterns.java new file mode 100644 index 000000000..8c4b80ce1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/publicsuffix/PublicSuffixPatterns.java @@ -0,0 +1,117 @@ +/* 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.util.publicsuffix; + +import android.content.Context; +import android.util.Log; + +import org.mozilla.gecko.util.IOUtils; + +import java.io.BufferedInputStream; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.HashSet; +import java.util.Set; + +class PublicSuffixPatterns { + /** If a hostname is contained as a key in this map, it is a public suffix. */ + private static Set<String> EXACT = null; + + static synchronized Set<String> getExactSet(Context context) { + if (EXACT != null) { + return EXACT; + } + + EXACT = new HashSet<>(); + + InputStream stream = null; + + try { + stream = context.getAssets().open("publicsuffixlist"); + BufferedReader reader = new BufferedReader(new InputStreamReader( + new BufferedInputStream(stream))); + + String line; + while ((line = reader.readLine()) != null) { + EXACT.add(line); + } + + } catch (IOException e) { + Log.e("Patterns", "IOException during loading public suffix list"); + } finally { + IOUtils.safeStreamClose(stream); + } + + return EXACT; + } + + + /** + * If a hostname is not a key in the EXCLUDE map, and if removing its + * leftmost component results in a name which is a key in this map, it is a + * public suffix. + */ + static final Set<String> UNDER = new HashSet<>(); + static { + UNDER.add("bd"); + UNDER.add("magentosite.cloud"); + UNDER.add("ke"); + UNDER.add("triton.zone"); + UNDER.add("compute.estate"); + UNDER.add("ye"); + UNDER.add("pg"); + UNDER.add("kh"); + UNDER.add("platform.sh"); + UNDER.add("fj"); + UNDER.add("ck"); + UNDER.add("fk"); + UNDER.add("alces.network"); + UNDER.add("sch.uk"); + UNDER.add("jm"); + UNDER.add("mm"); + UNDER.add("api.githubcloud.com"); + UNDER.add("ext.githubcloud.com"); + UNDER.add("0emm.com"); + UNDER.add("githubcloudusercontent.com"); + UNDER.add("cns.joyent.com"); + UNDER.add("bn"); + UNDER.add("yokohama.jp"); + UNDER.add("nagoya.jp"); + UNDER.add("kobe.jp"); + UNDER.add("sendai.jp"); + UNDER.add("kawasaki.jp"); + UNDER.add("sapporo.jp"); + UNDER.add("kitakyushu.jp"); + UNDER.add("np"); + UNDER.add("nom.br"); + UNDER.add("er"); + UNDER.add("cryptonomic.net"); + UNDER.add("gu"); + UNDER.add("kw"); + UNDER.add("zw"); + UNDER.add("mz"); + } + + /** + * The elements in this map would pass the UNDER test, but are known not to + * be public suffixes and are thus excluded from consideration. Since it + * refers to elements in UNDER of the same type, the type is actually not + * important here. The map is simply used for consistency reasons. + */ + static final Set<String> EXCLUDED = new HashSet<>(); + static { + EXCLUDED.add("www.ck"); + EXCLUDED.add("city.yokohama.jp"); + EXCLUDED.add("city.nagoya.jp"); + EXCLUDED.add("city.kobe.jp"); + EXCLUDED.add("city.sendai.jp"); + EXCLUDED.add("city.kawasaki.jp"); + EXCLUDED.add("city.sapporo.jp"); + EXCLUDED.add("city.kitakyushu.jp"); + EXCLUDED.add("teledata.mz"); + } +} |