diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 09:22:09 +0000 |
commit | 43a97878ce14b72f0981164f87f2e35e14151312 (patch) | |
tree | 620249daf56c0258faa40cbdcf9cfba06de2a846 /mobile/android/geckoview/src/main/java | |
parent | Initial commit. (diff) | |
download | firefox-43a97878ce14b72f0981164f87f2e35e14151312.tar.xz firefox-43a97878ce14b72f0981164f87f2e35e14151312.zip |
Adding upstream version 110.0.1.upstream/110.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/geckoview/src/main/java')
153 files changed, 58195 insertions, 0 deletions
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 0000000000..1dcd375d45 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/AndroidGamepadManager.java @@ -0,0 +1,414 @@ +/* -*- 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.hardware.input.InputManager; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; +import java.util.ArrayList; +import java.util.List; +import java.util.Timer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +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(final 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(final 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(final int axis, final int negativeButton, final 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(final int button) { + this.button = button; + } + } + + private static class Gamepad { + // ID from GamepadService + public byte[] handle; + // Retain axis state so we can determine changes. + public float axes[]; + public boolean dpad[]; + public int triggerAxes[]; + public float triggers[]; + + public Gamepad(final byte[] handle, final int deviceId) { + this.handle = handle; + axes = new float[Axis.values().length]; + dpad = new boolean[4]; + triggers = new float[2]; + + final 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") + private static native byte[] nativeAddGamepad(); + + @WrapForJNI(calledFrom = "ui") + private static native void nativeRemoveGamepad(byte[] aGamepadHandle); + + @WrapForJNI(calledFrom = "ui") + private static native void onButtonChange( + byte[] aGamepadHandle, int aButton, boolean aPressed, float aValue); + + @WrapForJNI(calledFrom = "ui") + private static native void onAxisChange(byte[] aGamepadHandle, boolean[] aValid, float[] aValues); + + 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(final Context context) { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + doStart(context); + } + }); + } + + /* package */ static void doStart(final Context context) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + scanForGamepads(); + addDeviceListener(context); + sStarted = true; + } + } + + @WrapForJNI + private static void stop(final Context context) { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + doStop(context); + } + }); + } + + /* package */ static void doStop(final Context context) { + ThreadUtils.assertOnUiThread(); + if (sStarted) { + removeDeviceListener(context); + sPendingGamepads.clear(); + sGamepads.clear(); + sStarted = false; + } + } + + /* package */ static void handleGamepadAdded(final int deviceId, final byte[] gamepadHandle) { + 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(gamepadHandle, deviceId)); + // Handle queued KeyEvents + for (final KeyEvent ev : pending) { + handleKeyEvent(ev); + } + } + + private static float sDeadZoneThresholdOverride = 1e-2f; + + private static boolean isValueInDeadZone(final MotionEvent event, final int axis) { + final float threshold; + if (sDeadZoneThresholdOverride >= 0) { + threshold = sDeadZoneThresholdOverride; + } else { + final InputDevice.MotionRange range = event.getDevice().getMotionRange(axis); + threshold = range.getFlat() + range.getFuzz(); + } + final float value = event.getAxisValue(axis); + return (Math.abs(value) < threshold); + } + + private static float deadZone(final MotionEvent ev, final int axis) { + if (isValueInDeadZone(ev, axis)) { + return 0.0f; + } + return ev.getAxisValue(axis); + } + + private static void mapDpadAxis( + final Gamepad gamepad, final boolean pressed, final float value, final int which) { + if (pressed != gamepad.dpad[which]) { + gamepad.dpad[which] = pressed; + onButtonChange(gamepad.handle, FIRST_DPAD_BUTTON + which, pressed, Math.abs(value)); + } + } + + public static boolean handleMotionEvent(final 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 + final boolean[] valid = new boolean[Axis.values().length]; + final float[] axes = new float[Axis.values().length]; + boolean anyValidAxes = false; + for (final Axis axis : Axis.values()) { + final float value = deadZone(ev, axis.axis); + final 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.handle, valid, axes); + } + + // Map triggers to buttons. + if (gamepad.triggerAxes != null) { + for (final Trigger trigger : Trigger.values()) { + final int i = trigger.ordinal(); + final int axis = gamepad.triggerAxes[i]; + final float value = deadZone(ev, axis); + if (value != gamepad.triggers[i]) { + gamepad.triggers[i] = value; + final boolean pressed = value > TRIGGER_PRESSED_THRESHOLD; + onButtonChange(gamepad.handle, trigger.button, pressed, value); + } + } + } + // Map d-pad to buttons. + for (final DpadAxis dpadaxis : DpadAxis.values()) { + final 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(final KeyEvent ev) { + ThreadUtils.assertOnUiThread(); + if (!sStarted) { + return false; + } + + final 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) { + final 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 (final 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; + } + + final Gamepad gamepad = sGamepads.get(deviceId); + final boolean pressed = ev.getAction() == KeyEvent.ACTION_DOWN; + onButtonChange(gamepad.handle, key, pressed, pressed ? 1.0f : 0.0f); + return true; + } + + private static void scanForGamepads() { + final int[] deviceIds = InputDevice.getDeviceIds(); + if (deviceIds == null) { + return; + } + for (int i = 0; i < deviceIds.length; i++) { + final 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(final InputDevice device) { + sPendingGamepads.put(device.getId(), new ArrayList<KeyEvent>()); + final byte[] gamepadId = nativeAddGamepad(); + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + handleGamepadAdded(device.getId(), gamepadId); + } + }); + } + + private static void removeGamepad(final int deviceId) { + final Gamepad gamepad = sGamepads.get(deviceId); + nativeRemoveGamepad(gamepad.handle); + sGamepads.remove(deviceId); + } + + private static void addDeviceListener(final Context context) { + sListener = + new InputManager.InputDeviceListener() { + @Override + public void onInputDeviceAdded(final int deviceId) { + final InputDevice device = InputDevice.getDevice(deviceId); + if (device == null) { + return; + } + if ((device.getSources() & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) { + addGamepad(device); + } + } + + @Override + public void onInputDeviceRemoved(final 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(final int deviceId) {} + }; + final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + im.registerInputDeviceListener(sListener, ThreadUtils.getUiHandler()); + } + + private static void removeDeviceListener(final Context context) { + final InputManager im = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + im.unregisterInputDeviceListener(sListener); + sListener = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java new file mode 100644 index 0000000000..9de47406aa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/Clipboard.java @@ -0,0 +1,181 @@ +/* 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.ClipData; +import android.content.ClipDescription; +import android.content.ClipboardManager; +import android.content.Context; +import android.os.Build; +import android.text.TextUtils; +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class Clipboard { + private static final String HTML_MIME = "text/html"; + private static final String UNICODE_MIME = "text/unicode"; + private static final String LOGTAG = "GeckoClipboard"; + + private Clipboard() {} + + /** + * Get the text on the primary clip on Android clipboard + * + * @param context application context. + * @return a plain text string of clipboard data. + */ + public static String getText(final Context context) { + return getData(context, UNICODE_MIME); + } + + /** + * Get the data on the primary clip on clipboard + * + * @param context application context + * @param mimeType the mime type we want. This supports text/html and text/unicode only. If other + * type, we do nothing. + * @return a string into clipboard. + */ + @WrapForJNI(calledFrom = "gecko") + public static String getData(final Context context, final String mimeType) { + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm.hasPrimaryClip()) { + final ClipData clip = cm.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return null; + } + + final ClipDescription description = clip.getDescription(); + if (HTML_MIME.equals(mimeType) + && description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML)) { + final CharSequence data = clip.getItemAt(0).getHtmlText(); + if (data == null) { + return null; + } + return data.toString(); + } + if (UNICODE_MIME.equals(mimeType)) { + try { + return clip.getItemAt(0).coerceToText(context).toString(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Couldn't get clip data from clipboard", e); + } + } + } + return null; + } + + /** + * Set plain text to clipboard + * + * @param context application context + * @param text a plain text to set to clipboard + * @return true if copy is successful. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean setText(final Context context, final CharSequence text) { + return setData(context, ClipData.newPlainText("text", text)); + } + + /** + * Store HTML to clipboard + * + * @param context application context + * @param text a plain text to set to clipboard + * @param html a html text to set to clipboard + * @return true if copy is successful. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean setHTML( + final Context context, final CharSequence text, final String htmlText) { + return setData(context, ClipData.newHtmlText("html", text, htmlText)); + } + + /** + * Store {@link android.content.ClipData} to clipboard + * + * @param context application context + * @param clipData a {@link android.content.ClipData} to set to clipboard + * @return true if copy is successful. + */ + private static boolean setData(final Context context, final ClipData clipData) { + // In API Level 11 and above, CLIPBOARD_SERVICE returns android.content.ClipboardManager, + // which is a subclass of android.text.ClipboardManager. + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + try { + cm.setPrimaryClip(clipData); + } catch (final 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. + } catch (final RuntimeException e) { + // If clipData is too large, TransactionTooLargeException occurs. + Log.e(LOGTAG, "Couldn't set clip data to clipboard", e); + return false; + } + return true; + } + + /** + * Check whether primary clipboard has given MIME type. + * + * @param context application context + * @param mimeType MIME type + * @return true if the clipboard is nonempty, false otherwise. + */ + @WrapForJNI(calledFrom = "gecko") + public static boolean hasData(final Context context, final String mimeType) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + if (HTML_MIME.equals(mimeType) || UNICODE_MIME.equals(mimeType)) { + return !TextUtils.isEmpty(getData(context, mimeType)); + } + return false; + } + + // Calling getPrimaryClip causes a toast message from Android 12. + // https://developer.android.com/about/versions/12/behavior-changes-all#clipboard-access-notifications + + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + + if (!cm.hasPrimaryClip()) { + return false; + } + + final ClipDescription description = cm.getPrimaryClipDescription(); + if (description == null) { + return false; + } + + if (HTML_MIME.equals(mimeType)) { + return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML); + } + + if (UNICODE_MIME.equals(mimeType)) { + // We cannot check content in data at this time to avoid toast message. + return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) + || description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN); + } + + return false; + } + + /** Deletes all text from the clipboard. */ + @WrapForJNI(calledFrom = "gecko") + public static void clearText(final Context context) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + setText(context, null); + return; + } + // Although we don't know more details of https://crbug.com/1203377, Blink doesn't use + // clearPrimaryClip on Android P since this may throw an exception, even if it is supported + // on Android P. + final ClipboardManager cm = + (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); + cm.clearPrimaryClip(); + } +} 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 0000000000..91bd44b552 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/CrashHandler.java @@ -0,0 +1,537 @@ +/* -*- 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.annotation.SuppressLint; +import android.app.Service; +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; +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 org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoRuntime; + +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; + + protected final Class<? extends Service> handlerService; + + /** + * Get the root exception from the 'cause' chain of an exception. + * + * @param exc An exception + * @return The root exception + */ + public static Throwable getRootException(final Throwable exc) { + Throwable cause; + Throwable result = exc; + for (cause = exc; cause != null; cause = cause.getCause()) { + result = cause; + } + + return result; + } + + /** + * 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) { + final StringWriter sw = new StringWriter(); + final 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(final Class<? extends Service> handlerService) { + this((Context) null, handlerService); + } + + /** + * Create and register a CrashHandler for all threads and thread groups. + * + * @param appContext A Context for retrieving application information. + */ + public CrashHandler(final Context appContext, final Class<? extends Service> handlerService) { + this.appContext = appContext; + this.handlerThread = null; + this.handlerService = handlerService; + 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, final Class<? extends Service> handlerService) { + this(thread, null, handlerService); + } + + /** + * 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, + final Class<? extends Service> handlerService) { + this.appContext = appContext; + this.handlerThread = thread; + this.handlerService = handlerService; + 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 (final 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. + final 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(); + + extras.putLong("CrashTime", getCrashTime()); + extras.putLong("StartupTime", getStartupTime()); + extras.putString("Android_ProcessName", getProcessName()); + extras.putString("Android_PackageName", pkgName); + + final String notes = GeckoAppShell.getAppNotes(); + if (notes != null) { + extras.putString("Notes", notes); + } + + 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 ProcessBuilder pb; + + if (handlerService == null) { + Log.w(LOGTAG, "No crash handler service defined, unable to report crash"); + return false; + } + + if (context != null) { + final Intent intent = new Intent(GeckoRuntime.ACTION_CRASHED); + intent.putExtra(GeckoRuntime.EXTRA_MINIDUMP_PATH, dumpFile); + intent.putExtra(GeckoRuntime.EXTRA_EXTRAS_PATH, extraFile); + intent.putExtra( + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + intent.setClass(context, handlerService); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent); + } else { + context.startService(intent); + } + return true; + } + + final int deviceSdkVersion = Build.VERSION.SDK_INT; + if (deviceSdkVersion < 17) { + pb = + new ProcessBuilder( + "/system/bin/am", + "startservice", + "-a", + GeckoRuntime.ACTION_CRASHED, + "-n", + getAppPackageName() + '/' + handlerService.getName(), + "--es", + GeckoRuntime.EXTRA_MINIDUMP_PATH, + dumpFile, + "--es", + GeckoRuntime.EXTRA_EXTRAS_PATH, + extraFile, + "--es", + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, + GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + } else { + final String startServiceCommand; + if (deviceSdkVersion >= 26) { + startServiceCommand = "start-foreground-service"; + } else { + startServiceCommand = "startservice"; + } + + pb = + new ProcessBuilder( + "/system/bin/am", + startServiceCommand, + "--user", /* USER_CURRENT_OR_SELF */ + "-3", + "-a", + GeckoRuntime.ACTION_CRASHED, + "-n", + getAppPackageName() + '/' + handlerService.getName(), + "--es", + GeckoRuntime.EXTRA_MINIDUMP_PATH, + dumpFile, + "--es", + GeckoRuntime.EXTRA_EXTRAS_PATH, + extraFile, + "--es", + GeckoRuntime.EXTRA_CRASH_PROCESS_TYPE, + GeckoRuntime.CRASHED_PROCESS_TYPE_MAIN); + } + + 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 + */ + @SuppressLint("SdCardPath") + 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 JSONObject json = new JSONObject(); + for (final String key : extras.keySet()) { + json.put(key, extras.get(key)); + } + + final BufferedWriter extraWriter = new BufferedWriter(new FileWriter(extraFile)); + try { + extraWriter.write(json.toString()); + } finally { + extraWriter.close(); + } + } catch (final IOException | JSONException 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(final Thread thread, final Throwable exc) { + if (this.crashing) { + // Prevent possible infinite recusions. + return; + } + + Thread resolvedThread = thread; + if (resolvedThread == null) { + // Gecko may pass in null for thread to denote the current thread. + resolvedThread = Thread.currentThread(); + } + + try { + Throwable rootException = exc; + if (!this.unregistered) { + // Only process crash ourselves if we have not been unregistered. + + this.crashing = true; + rootException = getRootException(exc); + logException(resolvedThread, rootException); + + if (reportException(resolvedThread, rootException)) { + // Reporting succeeded; we can terminate our process now. + return; + } + } + + if (systemUncaughtHandler != null) { + // Follow the chain of uncaught handlers. + systemUncaughtHandler.uncaughtException(resolvedThread, rootException); + } + } finally { + terminateProcess(); + } + } + + public static CrashHandler createDefaultCrashHandler(final Context context) { + return new CrashHandler(context, null) { + @Override + protected Bundle getCrashExtras(final Thread thread, final Throwable exc) { + final Bundle extras = super.getCrashExtras(thread, exc); + + extras.putString("ProductName", BuildConfig.MOZ_APP_BASENAME); + extras.putString("ProductID", BuildConfig.MOZ_APP_ID); + extras.putString("Version", BuildConfig.MOZ_APP_VERSION); + extras.putString("BuildID", BuildConfig.MOZ_APP_BUILDID); + extras.putString("Vendor", BuildConfig.MOZ_APP_VENDOR); + extras.putString("ReleaseChannel", BuildConfig.MOZ_UPDATE_CHANNEL); + return extras; + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + if (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.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/EnterpriseRoots.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java new file mode 100644 index 0000000000..0aacef39a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EnterpriseRoots.java @@ -0,0 +1,96 @@ +/* -*- 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.util.Log; +import java.io.IOException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Enumeration; +import org.mozilla.gecko.annotation.WrapForJNI; + +// This class implements the functionality needed to find third-party root +// certificates that have been added to the android CA store. +public class EnterpriseRoots { + private static final String LOGTAG = "EnterpriseRoots"; + + // Gecko calls this function from C++ to find third-party root certificates + // it can use as trust anchors for TLS connections. + @WrapForJNI + private static byte[][] gatherEnterpriseRoots() { + + // The KeyStore "AndroidCAStore" contains the certificates we're + // interested in. + final KeyStore ks; + try { + ks = KeyStore.getInstance("AndroidCAStore"); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "getInstance() failed", kse); + return new byte[0][0]; + } + try { + ks.load(null); + } catch (final CertificateException ce) { + Log.e(LOGTAG, "load() failed", ce); + return new byte[0][0]; + } catch (final IOException ioe) { + Log.e(LOGTAG, "load() failed", ioe); + return new byte[0][0]; + } catch (final NoSuchAlgorithmException nsae) { + Log.e(LOGTAG, "load() failed", nsae); + return new byte[0][0]; + } + // Given the KeyStore, we get an identifier for each object in it. For + // each one that is a Certificate, we try to distinguish between + // entries that shipped with the OS and entries that were added by the + // user or an administrator. The former we ignore and the latter we + // collect in an array of byte arrays and return. + final Enumeration<String> aliases; + try { + aliases = ks.aliases(); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "aliases() failed", kse); + return new byte[0][0]; + } + final ArrayList<byte[]> roots = new ArrayList<byte[]>(); + while (aliases.hasMoreElements()) { + final String alias = aliases.nextElement(); + final boolean isCertificate; + try { + isCertificate = ks.isCertificateEntry(alias); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "isCertificateEntry() failed", kse); + continue; + } + // Built-in certificate aliases start with "system:", whereas + // 3rd-party certificate aliases start with "user:". It's + // unfortunate to be relying on this implementation detail, but + // there appears to be no other way to differentiate between the + // two. + if (isCertificate && alias.startsWith("user:")) { + final Certificate certificate; + try { + certificate = ks.getCertificate(alias); + } catch (final KeyStoreException kse) { + Log.e(LOGTAG, "getCertificate() failed", kse); + continue; + } + try { + roots.add(certificate.getEncoded()); + } catch (final CertificateEncodingException cee) { + Log.e(LOGTAG, "getEncoded() failed", cee); + } + } + } + Log.d(LOGTAG, "found " + roots.size() + " enterprise roots"); + return roots.toArray(new byte[0][0]); + } +} 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 0000000000..647ac5bc09 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/EventDispatcher.java @@ -0,0 +1,588 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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 android.os.Handler; +import android.util.Log; +import androidx.annotation.AnyThread; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; + +@RobocopTarget +public final class EventDispatcher extends JNIObject { + private static final String LOGTAG = "GeckoEventDispatcher"; + + 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_UI_EVENTS_COUNT = 128; // Empirically measured + + private static class Message { + final String type; + final GeckoBundle bundle; + final EventCallback callback; + + Message(final String type, final GeckoBundle bundle, final EventCallback callback) { + this.type = type; + this.bundle = bundle; + this.callback = callback; + } + } + + // GeckoBundle-based events. + private final MultiMap<String, BundleEventListener> mListeners = + new MultiMap<>(DEFAULT_UI_EVENTS_COUNT); + private Deque<Message> mPendingMessages = new ArrayDeque<>(); + + private boolean mAttachedToGecko; + private final NativeQueue mNativeQueue; + private final String mName; + + private static Map<String, EventDispatcher> sDispatchers = new HashMap<>(); + + @ReflectionTarget + @WrapForJNI(calledFrom = "gecko") + public static EventDispatcher getInstance() { + return INSTANCE; + } + + /** + * Gets a named EventDispatcher. + * + * <p>Named EventDispatchers can be used to communicate to Gecko's corresponding named + * EventDispatcher. + * + * <p>Messages for named EventDispatcher are queued by default when no listener is present. Queued + * messages will be released automatically when a listener is attached. + * + * <p>A named EventDispatcher needs to be disposed manually by calling {@link #shutdown} when it + * is not needed anymore. + * + * @param name Name for this EventDispatcher. + * @return the existing named EventDispatcher for a given name or a newly created one if it + * doesn't exist. + */ + @ReflectionTarget + @WrapForJNI(calledFrom = "gecko") + public static EventDispatcher byName(final String name) { + synchronized (sDispatchers) { + EventDispatcher dispatcher = sDispatchers.get(name); + + if (dispatcher == null) { + dispatcher = new EventDispatcher(name); + sDispatchers.put(name, dispatcher); + } + + return dispatcher; + } + } + + /* package */ EventDispatcher() { + mNativeQueue = GeckoThread.getNativeQueue(); + mName = null; + } + + /* package */ EventDispatcher(final String name) { + mNativeQueue = GeckoThread.getNativeQueue(); + mName = name; + } + + public EventDispatcher(final NativeQueue queue) { + mNativeQueue = queue; + mName = null; + } + + private boolean isReadyForDispatchingToGecko() { + return mNativeQueue.isReady(); + } + + @WrapForJNI + @Override // JNIObject + protected native void disposeNative(); + + @WrapForJNI(stubName = "Shutdown") + protected native void shutdownNative(); + + @WrapForJNI private static final int DETACHED = 0; + @WrapForJNI private static final int ATTACHED = 1; + @WrapForJNI private static final int REATTACHING = 2; + + @WrapForJNI(calledFrom = "gecko") + private synchronized void setAttachedToGecko(final int state) { + if (mAttachedToGecko && state == DETACHED) { + dispose(false); + } + mAttachedToGecko = (state == ATTACHED); + } + + /** + * Shuts down this EventDispatcher and release resources. + * + * <p>Only named EventDispatcher can be shut down manually. A shut down EventDispatcher will not + * receive any further messages. + */ + public void shutdown() { + if (mName == null) { + throw new RuntimeException("Only named EventDispatcher's can be shut down."); + } + + mAttachedToGecko = false; + shutdownNative(); + dispose(false); + + synchronized (sDispatchers) { + sDispatchers.put(mName, null); + } + } + + private void dispose(final boolean force) { + final Handler geckoHandler = ThreadUtils.sGeckoHandler; + if (geckoHandler == null) { + return; + } + + geckoHandler.post( + new Runnable() { + @Override + public void run() { + if (force || !mAttachedToGecko) { + disposeNative(); + } + } + }); + } + + public void registerUiThreadListener(final BundleEventListener listener, final String... events) { + try { + synchronized (mListeners) { + for (final String event : events) { + if (!BuildConfig.RELEASE_OR_BETA && mListeners.containsEntry(event, listener)) { + throw new IllegalStateException("Already registered " + event); + } + mListeners.add(event, listener); + } + flush(events); + } + } catch (final Exception e) { + throw new IllegalArgumentException("Invalid new list type", e); + } + } + + public void unregisterUiThreadListener( + final BundleEventListener listener, final String... events) { + synchronized (mListeners) { + for (final String event : events) { + if (!mListeners.remove(event, listener) && !BuildConfig.RELEASE_OR_BETA) { + throw new IllegalArgumentException(event + " was not registered"); + } + } + } + } + + @WrapForJNI + private native boolean hasGeckoListener(final String event); + + @WrapForJNI(dispatchTo = "gecko") + private native void dispatchToGecko( + final String event, final GeckoBundle data, final EventCallback 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 GeckoBundle message) { + dispatch(type, message, /* callback */ null); + } + + private abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback { + @Override + public void sendError(final Object response) { + completeExceptionally(new QueryException(response)); + } + } + + public class QueryException extends Exception { + public final Object data; + + public QueryException(final Object data) { + this.data = data; + } + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes when the event handler returns. + * + * @param type Event type + */ + public GeckoResult<Void> queryVoid(final String type) { + return queryVoid(type, null); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes when the event handler returns. + * + * @param type Event type + * @param message GeckoBundle message + */ + public GeckoResult<Void> queryVoid(final String type, final GeckoBundle message) { + return query(type, message); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given boolean value returned by the handler. + * + * @param type Event type + */ + public GeckoResult<Boolean> queryBoolean(final String type) { + return queryBoolean(type, null); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given boolean value returned by the handler. + * + * @param type Event type + * @param message GeckoBundle message + */ + public GeckoResult<Boolean> queryBoolean(final String type, final GeckoBundle message) { + return query(type, message); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given String value returned by the handler. + * + * @param type Event type + */ + public GeckoResult<String> queryString(final String type) { + return queryString(type, null); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given String value returned by the handler. + * + * @param type Event type + * @param message GeckoBundle message + */ + public GeckoResult<String> queryString(final String type, final GeckoBundle message) { + return query(type, message); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the + * handler. + * + * @param type Event type + */ + public GeckoResult<GeckoBundle> queryBundle(final String type) { + return queryBundle(type, null); + } + + /** + * Query event to any registered Bundle listeners (non-Gecko thread listeners). + * + * <p>The returned GeckoResult completes with the given {@link GeckoBundle} value returned by the + * handler. + * + * @param type Event type + * @param message GeckoBundle message + */ + public GeckoResult<GeckoBundle> queryBundle(final String type, final GeckoBundle message) { + return query(type, message); + } + + private <T> GeckoResult<T> query(final String type, final GeckoBundle message) { + final CallbackResult<T> result = + new CallbackResult<T>() { + @Override + @SuppressWarnings("unchecked") // Not a lot we can do about this :( + public void sendSuccess(final Object response) { + complete((T) response); + } + }; + + dispatch(type, message, result); + return result; + } + + /** + * Flushes pending messages of given types. + * + * <p>All unhandled messages are put into a pending state by default for named EventDispatcher + * obtained from {@link #byName}. + * + * @param types Types of message to flush. + */ + private void flush(final String[] types) { + final Set<String> typeSet = new HashSet<>(Arrays.asList(types)); + + final Deque<Message> pendingMessages; + synchronized (mPendingMessages) { + pendingMessages = mPendingMessages; + mPendingMessages = new ArrayDeque<>(pendingMessages.size()); + } + + Message message; + while (!pendingMessages.isEmpty()) { + message = pendingMessages.removeFirst(); + if (typeSet.contains(message.type)) { + dispatchToThreads(message.type, message.bundle, message.callback); + } else { + synchronized (mPendingMessages) { + mPendingMessages.addLast(message); + } + } + } + } + + /** + * 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. + */ + @AnyThread + private void dispatch( + final String type, final GeckoBundle message, final EventCallback callback) { + final boolean isGeckoReady; + synchronized (this) { + isGeckoReady = isReadyForDispatchingToGecko(); + if (isGeckoReady && mAttachedToGecko && hasGeckoListener(type)) { + dispatchToGecko(type, message, JavaCallbackDelegate.wrap(callback)); + return; + } + } + + dispatchToThreads(type, message, callback, isGeckoReady); + } + + @WrapForJNI(calledFrom = "gecko") + private boolean dispatchToThreads( + final String type, final GeckoBundle message, final EventCallback callback) { + return dispatchToThreads(type, message, callback, /* isGeckoReady */ true); + } + + private boolean dispatchToThreads( + final String type, + final GeckoBundle message, + final EventCallback callback, + final boolean isGeckoReady) { + // 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 (mListeners) { + if (mListeners.containsKey(type)) { + // Use a delegate to make sure callbacks happen on a specific thread. + final EventCallback wrappedCallback = JavaCallbackDelegate.wrap(callback); + + // Event listeners will call | callback.sendError | if applicable. + for (final BundleEventListener listener : mListeners.get(type)) { + ThreadUtils.getUiHandler() + .post( + new Runnable() { + @Override + public void run() { + final Double startTime = GeckoJavaSampler.tryToGetProfilerTime(); + listener.handleMessage(type, message, wrappedCallback); + GeckoJavaSampler.addMarker( + "EventDispatcher handleMessage", startTime, null, type); + } + }); + } + return true; + } + } + + if (!isGeckoReady) { + // Usually, we discard an event if there is no listeners for it by + // the time of the dispatch. However, if Gecko(View) is not ready and + // there is no listener for this event that's possibly headed to + // Gecko, we make a special exception to queue this event until + // Gecko(View) is ready. This way, Gecko can first register its + // listeners, and accept the event when it is ready. + mNativeQueue.queueUntilReady( + this, + "dispatchToGecko", + String.class, + type, + GeckoBundle.class, + message, + EventCallback.class, + JavaCallbackDelegate.wrap(callback)); + return true; + } + + // Named EventDispatchers use pending messages + if (mName != null) { + synchronized (mPendingMessages) { + mPendingMessages.addLast(new Message(type, message, callback)); + } + return true; + } + + final String error = "No listener for " + type; + if (callback != null) { + callback.sendError(error); + } + + Log.w(LOGTAG, error); + return false; + } + + @WrapForJNI + public boolean hasListener(final String event) { + synchronized (mListeners) { + return mListeners.containsKey(event); + } + } + + @Override + protected void finalize() throws Throwable { + dispose(true); + } + + private static class NativeCallbackDelegate extends JNIObject implements EventCallback { + @WrapForJNI(calledFrom = "gecko") + private NativeCallbackDelegate() {} + + @Override // JNIObject + protected void disposeNative() { + // We dispose in finalize(). + throw new UnsupportedOperationException(); + } + + @WrapForJNI(dispatchTo = "proxy") + @Override // EventCallback + public native void sendSuccess(Object response); + + @WrapForJNI(dispatchTo = "proxy") + @Override // EventCallback + public native void sendError(Object response); + + @WrapForJNI(dispatchTo = "gecko") + @Override // Object + protected native void finalize(); + } + + private static class JavaCallbackDelegate implements EventCallback { + private final Thread mOriginalThread = Thread.currentThread(); + private final EventCallback mCallback; + + public static EventCallback wrap(final EventCallback callback) { + if (callback == null) { + return null; + } + if (callback instanceof NativeCallbackDelegate) { + // NativeCallbackDelegate always posts to Gecko thread if needed. + return callback; + } + return new JavaCallbackDelegate(callback); + } + + JavaCallbackDelegate(final EventCallback callback) { + mCallback = callback; + } + + private void makeCallback(final boolean callSuccess, final Object rawResponse) { + final Object response; + if (rawResponse instanceof Number) { + // There is ambiguity because a number can be converted to either int or + // double, so e.g. the user can be expecting a double when we give it an + // int. To avoid these pitfalls, we disallow all numbers. The workaround + // is to wrap the number in a JS object / GeckoBundle, which supports + // type coersion for numbers. + throw new UnsupportedOperationException("Cannot use number as Java callback result"); + } else if (rawResponse != null && rawResponse.getClass().isArray()) { + // Same with arrays. + throw new UnsupportedOperationException("Cannot use arrays as Java callback result"); + } else if (rawResponse instanceof Character) { + response = rawResponse.toString(); + } else { + response = rawResponse; + } + + // Call back synchronously if we happen to be on the same thread as the thread + // making the original request. + if (ThreadUtils.isOnThread(mOriginalThread)) { + if (callSuccess) { + mCallback.sendSuccess(response); + } else { + mCallback.sendError(response); + } + return; + } + + // Make callback on the thread of the original request, if the original thread + // is the UI or Gecko thread. Otherwise default to the background thread. + final Handler handler = + mOriginalThread == ThreadUtils.getUiThread() + ? ThreadUtils.getUiHandler() + : mOriginalThread == ThreadUtils.sGeckoThread + ? ThreadUtils.sGeckoHandler + : ThreadUtils.getBackgroundHandler(); + final EventCallback callback = mCallback; + + handler.post( + new Runnable() { + @Override + public void run() { + if (callSuccess) { + callback.sendSuccess(response); + } else { + callback.sendError(response); + } + } + }); + } + + @Override // EventCallback + public void sendSuccess(final Object response) { + makeCallback(/* success */ true, response); + } + + @Override // EventCallback + public void sendError(final Object response) { + makeCallback(/* success */ false, response); + } + } +} 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 0000000000..bb7408fbc2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoAppShell.java @@ -0,0 +1,1607 @@ +/* -*- 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.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.ActivityManager; +import android.app.Service; +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.PackageManager; +import android.content.pm.ResolveInfo; +import android.content.res.TypedArray; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.PixelFormat; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.hardware.SensorEvent; +import android.hardware.SensorEventListener; +import android.hardware.SensorManager; +import android.hardware.display.DisplayManager; +import android.location.Criteria; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.media.AudioManager; +import android.net.ConnectivityManager; +import android.net.LinkProperties; +import android.net.Network; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.LocaleList; +import android.os.Looper; +import android.os.PowerManager; +import android.os.Vibrator; +import android.provider.Settings; +import android.text.TextUtils; +import android.util.Log; +import android.view.ContextThemeWrapper; +import android.view.Display; +import android.view.InputDevice; +import android.view.WindowManager; +import android.webkit.MimeTypeMap; +import androidx.annotation.Nullable; +import androidx.collection.SimpleArrayMap; +import androidx.core.content.res.ResourcesCompat; +import java.net.Proxy; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Locale; +import java.util.StringTokenizer; +import org.jetbrains.annotations.NotNull; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; +import org.mozilla.gecko.util.HardwareUtils; +import org.mozilla.gecko.util.InputDeviceUtils; +import org.mozilla.gecko.util.ProxySelector; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; +import org.mozilla.geckoview.R; + +public class GeckoAppShell { + private static final String LOGTAG = "GeckoAppShell"; + + /* + * 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; + + // We have static members only. + private GeckoAppShell() {} + + // Name for app-scoped prefs + public static final String APP_PREFS_NAME = "GeckoApp"; + + private static class GeckoCrashHandler extends CrashHandler { + + public GeckoCrashHandler(final Class<? extends Service> handlerService) { + super(handlerService); + } + + @Override + protected String getAppPackageName() { + final Context appContext = getAppContext(); + if (appContext == null) { + return "<unknown>"; + } + return appContext.getPackageName(); + } + + @Override + protected Context getAppContext() { + return getApplicationContext(); + } + + @Override + public boolean reportException(final Thread thread, final Throwable exc) { + try { + if (exc instanceof OutOfMemoryError) { + final SharedPreferences prefs = + getApplicationContext().getSharedPreferences(APP_PREFS_NAME, 0); + final 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 (BuildConfig.MOZ_CRASHREPORTER && BuildConfig.MOZILLA_OFFICIAL) { + // Only use Java crash reporter if enabled on official build. + return super.reportException(thread, exc); + } + return false; + } + } + + private static String sAppNotes; + private static CrashHandler sCrashHandler; + + public static synchronized CrashHandler ensureCrashHandling( + final Class<? extends Service> handler) { + if (sCrashHandler == null) { + sCrashHandler = new GeckoCrashHandler(handler); + } + + return sCrashHandler; + } + + private static Class<? extends Service> sCrashHandlerService; + + public static synchronized void setCrashHandlerService( + final Class<? extends Service> handlerService) { + sCrashHandlerService = handlerService; + } + + public static synchronized Class<? extends Service> getCrashHandlerService() { + return sCrashHandlerService; + } + + @WrapForJNI(exceptionMode = "ignore") + /* package */ static synchronized String getAppNotes() { + return sAppNotes; + } + + public static synchronized void appendAppNotesToCrashReport(final String notes) { + if (sAppNotes == null) { + sAppNotes = notes; + } else { + sAppNotes += '\n' + notes; + } + } + + private static volatile boolean locationHighAccuracyEnabled; + private static volatile boolean locationListeningRequested = false; + private static volatile boolean locationPaused = false; + + // See also HardwareUtils.LOW_MEMORY_THRESHOLD_MB. + private static final int HIGH_MEMORY_DEVICE_THRESHOLD_MB = 768; + + private static int sDensityDpi; + private static Float sDensity; + private static int sScreenDepth; + private static boolean sUseMaxScreenDepth; + private static Float sScreenRefreshRate; + + /* 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 gLightSensor; + private static Sensor gRotationVectorSensor; + private static Sensor gGameRotationVectorSensor; + + /* + * Keep in sync with constants found here: + * http://searchfox.org/mozilla-central/source/uriloader/base/nsIWebProgressListener.idl + */ + public static final int WPL_STATE_START = 0x00000001; + public static final int WPL_STATE_STOP = 0x00000010; + public static final int WPL_STATE_IS_DOCUMENT = 0x00020000; + public static final int WPL_STATE_IS_NETWORK = 0x00040000; + + /* Keep in sync with constants found here: + http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public static final int LINK_TYPE_UNKNOWN = 0; + public static final int LINK_TYPE_ETHERNET = 1; + public static final int LINK_TYPE_USB = 2; + public static final int LINK_TYPE_WIFI = 3; + public static final int LINK_TYPE_WIMAX = 4; + public static final int LINK_TYPE_MOBILE = 9; + + 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); + + private static Rect sScreenSizeOverride; + + @WrapForJNI(stubName = "NotifyObservers", dispatchTo = "gecko") + private static native void nativeNotifyObservers(String topic, String data); + + @WrapForJNI(stubName = "AppendAppNotesToCrashReport", dispatchTo = "gecko") + public static native void nativeAppendAppNotesToCrashReport(final String notes); + + @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(final Throwable e) { + return CrashHandler.getExceptionStackTrace(CrashHandler.getRootException(e)); + } + + @WrapForJNI(exceptionMode = "ignore") + private static synchronized void handleUncaughtException(final Throwable e) { + if (sCrashHandler != null) { + sCrashHandler.uncaughtException(null, e); + } + } + + private static float getLocationAccuracy(final Location location) { + final float radius = location.getAccuracy(); + return (location.hasAccuracy() && radius > 0) ? radius : 1001; + } + + private static Location determineReliableLocation( + @NotNull final Location locA, @NotNull final Location locB) { + // The 6 seconds were chosen arbitrarily + final long closeTime = 6000000000L; + final boolean isNearSameTime = + Math.abs((locA.getElapsedRealtimeNanos() - locB.getElapsedRealtimeNanos())) <= closeTime; + final boolean isAMoreAccurate = getLocationAccuracy(locA) < getLocationAccuracy(locB); + final boolean isAMoreRecent = locA.getElapsedRealtimeNanos() > locB.getElapsedRealtimeNanos(); + if (isNearSameTime) { + return isAMoreAccurate ? locA : locB; + } + return isAMoreRecent ? locA : locB; + } + + // Permissions are explicitly checked when requesting content permission. + @SuppressLint("MissingPermission") + private static @Nullable Location getLastKnownLocation(final LocationManager lm) { + Location lastKnownLocation = null; + final List<String> providers = lm.getAllProviders(); + + for (final String provider : providers) { + final Location location = lm.getLastKnownLocation(provider); + if (location == null) { + continue; + } + + if (lastKnownLocation == null) { + lastKnownLocation = location; + continue; + } + lastKnownLocation = determineReliableLocation(lastKnownLocation, location); + } + return lastKnownLocation; + } + + // Toggles the location listeners on/off, which will then provide/stop location information + @WrapForJNI(calledFrom = "gecko") + private static synchronized boolean enableLocationUpdates(final boolean enable) { + locationListeningRequested = enable; + final boolean canListen = updateLocationListeners(); + if (!canListen && locationListeningRequested) { + // Didn't successfully start listener when requested + locationListeningRequested = false; + } + return canListen; + } + + // Permissions are explicitly checked when requesting content permission. + @SuppressLint("MissingPermission") + private static synchronized boolean updateLocationListeners() { + final boolean shouldListen = locationListeningRequested && !locationPaused; + final LocationManager lm = getLocationManager(getApplicationContext()); + if (lm == null) { + return false; + } + + if (!shouldListen) { + // Could not complete request, because paused + if (locationListeningRequested) { + return false; + } + lm.removeUpdates(sAndroidListeners); + return true; + } + + if (!lm.isProviderEnabled(LocationManager.GPS_PROVIDER) + && !lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) { + return false; + } + + if (!locationHighAccuracyEnabled) { + final Location lastKnownLocation = getLastKnownLocation(lm); + if (lastKnownLocation != null) { + sAndroidListeners.onLocationChanged(lastKnownLocation); + } + } + + final Criteria criteria = new Criteria(); + criteria.setSpeedRequired(false); + criteria.setBearingRequired(false); + criteria.setAltitudeRequired(false); + if (locationHighAccuracyEnabled) { + criteria.setAccuracy(Criteria.ACCURACY_FINE); + } else { + criteria.setAccuracy(Criteria.ACCURACY_COARSE); + } + + final String provider = lm.getBestProvider(criteria, true); + if (provider == null) { + return false; + } + + final Looper l = Looper.getMainLooper(); + lm.requestLocationUpdates(provider, 100, 0.5f, sAndroidListeners, l); + return true; + } + + public static void pauseLocation() { + locationPaused = true; + updateLocationListeners(); + } + + public static void resumeLocation() { + locationPaused = false; + updateLocationListeners(); + } + + private static LocationManager getLocationManager(final Context context) { + try { + return (LocationManager) context.getSystemService(Context.LOCATION_SERVICE); + } catch (final 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 = "ui", dispatchTo = "gecko") + /* package */ static native void onSensorChanged( + int halType, float x, float y, float z, float w, long time); + + @WrapForJNI(calledFrom = "any", dispatchTo = "gecko") + /* package */ static native void onLocationChanged( + double latitude, + double longitude, + double altitude, + float accuracy, + float altitudeAccuracy, + float heading, + float speed); + + private static class AndroidListeners implements SensorEventListener, LocationListener { + @Override + public void onAccuracyChanged(final Sensor sensor, final int accuracy) {} + + @Override + public void onSensorChanged(final SensorEvent s) { + final int sensorType = s.sensor.getType(); + int halType = 0; + float x = 0.0f, y = 0.0f, z = 0.0f, w = 0.0f; + // SensorEvent timestamp is in nanoseconds, Gecko expects microseconds. + final long time = s.timestamp / 1000; + + switch (sensorType) { + case Sensor.TYPE_ACCELEROMETER: + case Sensor.TYPE_LINEAR_ACCELERATION: + case Sensor.TYPE_ORIENTATION: + if (sensorType == Sensor.TYPE_ACCELEROMETER) { + halType = SENSOR_ACCELERATION; + } else if (sensorType == Sensor.TYPE_LINEAR_ACCELERATION) { + halType = SENSOR_LINEAR_ACCELERATION; + } else { + halType = SENSOR_ORIENTATION; + } + x = s.values[0]; + y = s.values[1]; + z = s.values[2]; + break; + + case Sensor.TYPE_GYROSCOPE: + halType = 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_LIGHT: + halType = SENSOR_LIGHT; + x = s.values[0]; + break; + + case Sensor.TYPE_ROTATION_VECTOR: + case Sensor.TYPE_GAME_ROTATION_VECTOR: // API >= 18 + halType = + (sensorType == Sensor.TYPE_ROTATION_VECTOR + ? SENSOR_ROTATION_VECTOR + : 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(halType, x, y, z, w, time); + } + + // Geolocation. + @Override + public void onLocationChanged(final Location location) { + // No logging here: user-identifying information. + + final double altitude = location.hasAltitude() ? location.getAltitude() : Double.NaN; + + final float accuracy = location.hasAccuracy() ? location.getAccuracy() : Float.NaN; + + final float altitudeAccuracy = + Build.VERSION.SDK_INT >= 26 && location.hasVerticalAccuracy() + ? location.getVerticalAccuracyMeters() + : Float.NaN; + + final float speed = location.hasSpeed() ? location.getSpeed() : Float.NaN; + + final float heading = location.hasBearing() ? location.getBearing() : Float.NaN; + + // nsGeoPositionCoords will convert NaNs to null for optional + // properties of the JavaScript Coordinates object. + GeckoAppShell.onLocationChanged( + location.getLatitude(), + location.getLongitude(), + altitude, + accuracy, + altitudeAccuracy, + heading, + speed); + } + + @Override + public void onProviderDisabled(final String provider) {} + + @Override + public void onProviderEnabled(final String provider) {} + + @Override + public void onStatusChanged(final String provider, final int status, final Bundle extras) {} + } + + private static final AndroidListeners sAndroidListeners = new AndroidListeners(); + + private static SimpleArrayMap<String, PowerManager.WakeLock> sWakeLocks; + + /** Wake-lock for the CPU. */ + static final String WAKE_LOCK_CPU = "cpu"; + /** Wake-lock for the screen. */ + static final String WAKE_LOCK_SCREEN = "screen"; + /** Wake-lock for the audio-playing, eqaul to LOCK_CPU. */ + static final String WAKE_LOCK_AUDIO_PLAYING = "audio-playing"; + /** Wake-lock for the video-playing, eqaul to LOCK_SCREEN.. */ + static final String WAKE_LOCK_VIDEO_PLAYING = "video-playing"; + + static final int WAKE_LOCKS_COUNT = 2; + + /** No one holds the wake-lock. */ + static final int WAKE_LOCK_STATE_UNLOCKED = 0; + /** The wake-lock is held by a foreground window. */ + static final int WAKE_LOCK_STATE_LOCKED_FOREGROUND = 1; + /** The wake-lock is held by a background window. */ + static final int WAKE_LOCK_STATE_LOCKED_BACKGROUND = 2; + + @SuppressLint("Wakelock") // We keep the wake lock independent from the function + // scope, so we need to suppress the linter warning. + private static void setWakeLockState(final String lock, final int state) { + if (sWakeLocks == null) { + sWakeLocks = new SimpleArrayMap<>(WAKE_LOCKS_COUNT); + } + + PowerManager.WakeLock wl = sWakeLocks.get(lock); + + // we should still hold the lock for background audio. + if (WAKE_LOCK_AUDIO_PLAYING.equals(lock) && state == WAKE_LOCK_STATE_LOCKED_BACKGROUND) { + return; + } + + if (state == WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl == null) { + final PowerManager pm = + (PowerManager) getApplicationContext().getSystemService(Context.POWER_SERVICE); + + if (WAKE_LOCK_CPU.equals(lock) || WAKE_LOCK_AUDIO_PLAYING.equals(lock)) { + wl = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, lock); + } else if (WAKE_LOCK_SCREEN.equals(lock) || WAKE_LOCK_VIDEO_PLAYING.equals(lock)) { + // ON_AFTER_RELEASE is set, the user activity timer will be reset when the + // WakeLock is released, causing the illumination to remain on a bit longer. + wl = + pm.newWakeLock( + PowerManager.SCREEN_BRIGHT_WAKE_LOCK | PowerManager.ON_AFTER_RELEASE, lock); + } else { + Log.w(LOGTAG, "Unsupported wake-lock: " + lock); + return; + } + + wl.acquire(); + sWakeLocks.put(lock, wl); + } else if (state != WAKE_LOCK_STATE_LOCKED_FOREGROUND && wl != null) { + wl.release(); + sWakeLocks.remove(lock); + } + } + + @SuppressWarnings("fallthrough") + @WrapForJNI(calledFrom = "gecko") + private static void enableSensor(final int aSensortype) { + final SensorManager sm = + (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor == null) { + gGameRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_GAME_ROTATION_VECTOR); + } + if (gGameRotationVectorSensor != null) { + sm.registerListener( + sAndroidListeners, gGameRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + if (gGameRotationVectorSensor != null) { + break; + } + // Fallthrough + + case SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor == null) { + gRotationVectorSensor = sm.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR); + } + if (gRotationVectorSensor != null) { + sm.registerListener( + sAndroidListeners, gRotationVectorSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + if (gRotationVectorSensor != null) { + break; + } + // Fallthrough + + case SENSOR_ORIENTATION: + if (gOrientationSensor == null) { + gOrientationSensor = sm.getDefaultSensor(Sensor.TYPE_ORIENTATION); + } + if (gOrientationSensor != null) { + sm.registerListener( + sAndroidListeners, gOrientationSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_ACCELERATION: + if (gAccelerometerSensor == null) { + gAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + } + if (gAccelerometerSensor != null) { + sm.registerListener( + sAndroidListeners, gAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_LIGHT: + if (gLightSensor == null) { + gLightSensor = sm.getDefaultSensor(Sensor.TYPE_LIGHT); + } + if (gLightSensor != null) { + sm.registerListener(sAndroidListeners, gLightSensor, SensorManager.SENSOR_DELAY_NORMAL); + } + break; + + case SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor == null) { + gLinearAccelerometerSensor = sm.getDefaultSensor(Sensor.TYPE_LINEAR_ACCELERATION); + } + if (gLinearAccelerometerSensor != null) { + sm.registerListener( + sAndroidListeners, gLinearAccelerometerSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + case SENSOR_GYROSCOPE: + if (gGyroscopeSensor == null) { + gGyroscopeSensor = sm.getDefaultSensor(Sensor.TYPE_GYROSCOPE); + } + if (gGyroscopeSensor != null) { + sm.registerListener( + sAndroidListeners, gGyroscopeSensor, SensorManager.SENSOR_DELAY_FASTEST); + } + break; + + default: + Log.w(LOGTAG, "Error! Can't enable unknown SENSOR type " + aSensortype); + } + } + + @SuppressWarnings("fallthrough") + @WrapForJNI(calledFrom = "gecko") + private static void disableSensor(final int aSensortype) { + final SensorManager sm = + (SensorManager) getApplicationContext().getSystemService(Context.SENSOR_SERVICE); + + switch (aSensortype) { + case SENSOR_GAME_ROTATION_VECTOR: + if (gGameRotationVectorSensor != null) { + sm.unregisterListener(sAndroidListeners, gGameRotationVectorSensor); + break; + } + // Fallthrough + + case SENSOR_ROTATION_VECTOR: + if (gRotationVectorSensor != null) { + sm.unregisterListener(sAndroidListeners, gRotationVectorSensor); + break; + } + // Fallthrough + + case SENSOR_ORIENTATION: + if (gOrientationSensor != null) { + sm.unregisterListener(sAndroidListeners, gOrientationSensor); + } + break; + + case SENSOR_ACCELERATION: + if (gAccelerometerSensor != null) { + sm.unregisterListener(sAndroidListeners, gAccelerometerSensor); + } + break; + + case SENSOR_LIGHT: + if (gLightSensor != null) { + sm.unregisterListener(sAndroidListeners, gLightSensor); + } + break; + + case SENSOR_LINEAR_ACCELERATION: + if (gLinearAccelerometerSensor != null) { + sm.unregisterListener(sAndroidListeners, gLinearAccelerometerSensor); + } + break; + + case SENSOR_GYROSCOPE: + if (gGyroscopeSensor != null) { + sm.unregisterListener(sAndroidListeners, gGyroscopeSensor); + } + break; + default: + Log.w(LOGTAG, "Error! Can't disable unknown SENSOR type " + aSensortype); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static void moveTaskToBack() { + // This is a vestige, to be removed as full-screen support for GeckoView is implemented. + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean hasHWVP8Encoder() { + return HardwareCodecCapabilityUtils.hasHWVP8(true /* aIsEncoder */); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean hasHWVP8Decoder() { + return HardwareCodecCapabilityUtils.hasHWVP8(false /* aIsEncoder */); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getExtensionFromMimeType(final String aMimeType) { + return MimeTypeMap.getSingleton().getExtensionFromMimeType(aMimeType); + } + + @WrapForJNI(calledFrom = "gecko") + public static String getMimeTypeFromExtensions(final String aFileExt) { + final StringTokenizer st = new StringTokenizer(aFileExt, ".,; "); + String type = null; + String subType = null; + while (st.hasMoreElements()) { + final String ext = st.nextToken(); + final String mt = getMimeTypeFromExtension(ext); + if (mt == null) continue; + final int slash = mt.indexOf('/'); + final String tmpType = mt.substring(0, slash); + if (!tmpType.equalsIgnoreCase(type)) type = type == null ? tmpType : "*"; + final 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; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void notifyAlertListener(String name, String topic, String 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); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + GeckoAppShell.class, + "notifyAlertListener", + name, + "alertclickcallback", + cookie); + } + } + + public static synchronized void setDisplayDpiOverride(@Nullable final Integer dpi) { + if (dpi == null) { + return; + } + if (sDensityDpi != 0) { + Log.e(LOGTAG, "Tried to override screen DPI after it's already been set"); + return; + } + sDensityDpi = dpi; + } + + @WrapForJNI(calledFrom = "gecko") + public static synchronized int getDpi() { + if (sDensityDpi == 0) { + sDensityDpi = getApplicationContext().getResources().getDisplayMetrics().densityDpi; + } + return sDensityDpi; + } + + public static synchronized void setDisplayDensityOverride(@Nullable final Float density) { + if (density == null) { + return; + } + if (sDensity != null) { + Log.e(LOGTAG, "Tried to override screen density after it's already been set"); + return; + } + sDensity = density; + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized float getDensity() { + if (sDensity == null) { + sDensity = Float.valueOf(getApplicationContext().getResources().getDisplayMetrics().density); + } + + return sDensity; + } + + private static int sTotalRam; + + private static int getTotalRam(final Context context) { + if (sTotalRam == 0) { + final ActivityManager.MemoryInfo memInfo = new ActivityManager.MemoryInfo(); + final ActivityManager am = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + am.getMemoryInfo(memInfo); // `getMemoryInfo()` returns a value in B. Convert to MB. + sTotalRam = (int) (memInfo.totalMem / (1024 * 1024)); + Log.d(LOGTAG, "System memory: " + sTotalRam + "MB."); + } + + return sTotalRam; + } + + private static boolean isHighMemoryDevice(final Context context) { + return getTotalRam(context) > HIGH_MEMORY_DEVICE_THRESHOLD_MB; + } + + public static synchronized void useMaxScreenDepth(final boolean enable) { + sUseMaxScreenDepth = enable; + } + + /** Returns the colour depth of the default screen. This will either be 32, 24 or 16. */ + @WrapForJNI(calledFrom = "gecko") + public static synchronized int getScreenDepth() { + if (sScreenDepth == 0) { + sScreenDepth = 16; + final Context applicationContext = getApplicationContext(); + final PixelFormat info = new PixelFormat(); + final WindowManager wm = + (WindowManager) applicationContext.getSystemService(Context.WINDOW_SERVICE); + PixelFormat.getPixelFormatInfo(wm.getDefaultDisplay().getPixelFormat(), info); + if (info.bitsPerPixel >= 24 && isHighMemoryDevice(applicationContext)) { + sScreenDepth = sUseMaxScreenDepth ? info.bitsPerPixel : 24; + } + } + + return sScreenDepth; + } + + @WrapForJNI(calledFrom = "gecko") + public static synchronized float getScreenRefreshRate() { + if (sScreenRefreshRate != null) { + return sScreenRefreshRate; + } + + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final float refreshRate = wm.getDefaultDisplay().getRefreshRate(); + // Android 11+ supports multiple refresh rate. So we have to get refresh rate per call. + // https://source.android.com/docs/core/graphics/multiple-refresh-rate + if (Build.VERSION.SDK_INT < 30) { + // Until Android 10, refresh rate is fixed, so we can cache it. + sScreenRefreshRate = Float.valueOf(refreshRate); + } + return refreshRate; + } + + @WrapForJNI(calledFrom = "gecko") + private static void performHapticFeedback(final 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) { + final int[] pattern; + if (aIsLongPress) { + pattern = new int[] {0, 1, 20, 21}; + } else { + pattern = new int[] {0, 10, 20, 30}; + } + vibrateOnHapticFeedbackEnabled(pattern); + sVibrationMaybePlaying = false; + sVibrationEndTime = 0; + } + } + + private static Vibrator vibrator() { + return (Vibrator) getApplicationContext().getSystemService(Context.VIBRATOR_SERVICE); + } + + // Helper method to convert integer array to long array. + private static long[] convertIntToLongArray(final int[] input) { + final 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. + private static void vibrateOnHapticFeedbackEnabled(final int[] milliseconds) { + if (Settings.System.getInt( + getApplicationContext().getContentResolver(), + Settings.System.HAPTIC_FEEDBACK_ENABLED, + 0) + > 0) { + if (milliseconds.length == 1) { + vibrate(milliseconds[0]); + } else { + vibrate(convertIntToLongArray(milliseconds), -1); + } + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(final long milliseconds) { + sVibrationEndTime = System.nanoTime() + milliseconds * 1000000; + sVibrationMaybePlaying = true; + try { + vibrator().vibrate(milliseconds); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void vibrate(final long[] pattern, final int repeat) { + // If pattern.length is odd, the last element in the pattern is a + // meaningless delay, so don't include it in vibrationDuration. + long vibrationDuration = 0; + final int iterLen = pattern.length & ~1; + for (int i = 0; i < iterLen; i++) { + vibrationDuration += pattern[i]; + } + + sVibrationEndTime = System.nanoTime() + vibrationDuration * 1000000; + sVibrationMaybePlaying = true; + try { + vibrator().vibrate(pattern, repeat); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + @SuppressLint("MissingPermission") + @WrapForJNI(calledFrom = "gecko") + private static void cancelVibrate() { + sVibrationMaybePlaying = false; + sVibrationEndTime = 0; + try { + vibrator().cancel(); + } catch (final SecurityException ignore) { + Log.w(LOGTAG, "No VIBRATE permission"); + } + } + + private static ConnectivityManager sConnectivityManager; + + private static void ensureConnectivityManager() { + if (sConnectivityManager == null) { + sConnectivityManager = + (ConnectivityManager) + getApplicationContext().getSystemService(Context.CONNECTIVITY_SERVICE); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkUp() { + ensureConnectivityManager(); + try { + final NetworkInfo info = sConnectivityManager.getActiveNetworkInfo(); + if (info == null || !info.isConnected()) return false; + } catch (final SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean isNetworkLinkKnown() { + ensureConnectivityManager(); + try { + if (sConnectivityManager.getActiveNetworkInfo() == null) return false; + } catch (final SecurityException se) { + return false; + } + return true; + } + + @WrapForJNI(calledFrom = "gecko") + private static int getNetworkLinkType() { + ensureConnectivityManager(); + final NetworkInfo info = sConnectivityManager.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: + return LINK_TYPE_MOBILE; + default: + Log.w(LOGTAG, "Ignoring the current network type."); + return LINK_TYPE_UNKNOWN; + } + } + + @WrapForJNI(calledFrom = "gecko") + private static String getDNSDomains() { + if (Build.VERSION.SDK_INT < 23) { + return ""; + } + + ensureConnectivityManager(); + final Network net = sConnectivityManager.getActiveNetwork(); + if (net == null) { + return ""; + } + + final LinkProperties lp = sConnectivityManager.getLinkProperties(net); + if (lp == null) { + return ""; + } + + return lp.getDomains(); + } + + @SuppressLint("ResourceType") + @WrapForJNI(calledFrom = "gecko") + private static int[] getSystemColors() { + // attrsAppearance[] must correspond to AndroidSystemColors structure in android/nsLookAndFeel.h + final int[] attrsAppearance = { + 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, + Build.VERSION.SDK_INT >= 21 ? android.R.attr.colorAccent : 0, + }; + + final int[] result = new int[attrsAppearance.length]; + + final ContextThemeWrapper contextThemeWrapper = + new ContextThemeWrapper(getApplicationContext(), android.R.style.TextAppearance); + + final TypedArray appearance = contextThemeWrapper.obtainStyledAttributes(attrsAppearance); + + if (appearance != null) { + for (int i = 0; i < appearance.getIndexCount(); i++) { + final int idx = appearance.getIndex(i); + final int color = appearance.getColor(idx, 0); + result[idx] = color; + } + appearance.recycle(); + } + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static byte[] getIconForExtension(final String aExt, final int iconSize) { + try { + int resolvedIconSize = iconSize; + if (iconSize <= 0) { + resolvedIconSize = 16; + } + + String resolvedExt = aExt; + if (aExt != null && aExt.length() > 1 && aExt.charAt(0) == '.') { + resolvedExt = aExt.substring(1); + } + + final PackageManager pm = getApplicationContext().getPackageManager(); + Drawable icon = getDrawableForExtension(pm, resolvedExt); + if (icon == null) { + // Use a generic icon. + icon = + ResourcesCompat.getDrawable( + getApplicationContext().getResources(), + R.drawable.ic_generic_file, + getApplicationContext().getTheme()); + } + + Bitmap bitmap = getBitmapFromDrawable(icon); + if (bitmap.getWidth() != resolvedIconSize || bitmap.getHeight() != resolvedIconSize) { + bitmap = Bitmap.createScaledBitmap(bitmap, resolvedIconSize, resolvedIconSize, true); + } + + final ByteBuffer buf = ByteBuffer.allocate(resolvedIconSize * resolvedIconSize * 4); + bitmap.copyPixelsToBuffer(buf); + + return buf.array(); + } catch (final Exception e) { + Log.w(LOGTAG, "getIconForExtension failed.", e); + return null; + } + } + + private static Bitmap getBitmapFromDrawable(final 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; + + final Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(bitmap); + drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight()); + drawable.draw(canvas); + + return bitmap; + } + + public static String getMimeTypeFromExtension(final String ext) { + final MimeTypeMap mtm = MimeTypeMap.getSingleton(); + return mtm.getMimeTypeFromExtension(ext); + } + + private static Drawable getDrawableForExtension(final PackageManager pm, final String aExt) { + final Intent intent = new Intent(Intent.ACTION_VIEW); + final String mimeType = getMimeTypeFromExtension(aExt); + if (mimeType != null && mimeType.length() > 0) intent.setType(mimeType); + else return null; + + final List<ResolveInfo> list = pm.queryIntentActivities(intent, 0); + if (list.size() == 0) return null; + + final ResolveInfo resolveInfo = list.get(0); + + if (resolveInfo == null) return null; + + final ActivityInfo activityInfo = resolveInfo.activityInfo; + + return activityInfo.loadIcon(pm); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean getShowPasswordSetting() { + try { + final int showPassword = + Settings.System.getInt( + getApplicationContext().getContentResolver(), Settings.System.TEXT_SHOW_PASSWORD, 1); + return (showPassword > 0); + } catch (final Exception e) { + return true; + } + } + + private static Context sApplicationContext; + private static Boolean sIs24HourFormat = true; + + @WrapForJNI + public static Context getApplicationContext() { + return sApplicationContext; + } + + public static void setApplicationContext(final Context context) { + sApplicationContext = context; + } + + /* + * Battery API related methods. + */ + @WrapForJNI(calledFrom = "gecko") + private static void enableBatteryNotifications() { + GeckoBatteryManager.enableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableBatteryNotifications() { + GeckoBatteryManager.disableNotifications(); + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentBatteryInformation() { + return GeckoBatteryManager.getCurrentInformation(); + } + + /* Called by JNI from AndroidBridge, and by reflection from tests/BaseTest.java.in */ + @WrapForJNI(calledFrom = "gecko") + @RobocopTarget + public static boolean isTablet() { + return HardwareUtils.isTablet(getApplicationContext()); + } + + @WrapForJNI(calledFrom = "gecko") + private static double[] getCurrentNetworkInformation() { + return GeckoNetworkManager.getInstance().getCurrentInformation(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void enableNetworkNotifications() { + ThreadUtils.runOnUiThread(() -> GeckoNetworkManager.getInstance().enableNotifications()); + } + + @WrapForJNI(calledFrom = "gecko") + private static void disableNetworkNotifications() { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + GeckoNetworkManager.getInstance().disableNotifications(); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private static short getScreenOrientation() { + return GeckoScreenOrientation.getInstance().getScreenOrientation().value; + } + + /* package */ static int getRotation() { + return sScreenCompat.getRotation(); + } + + @WrapForJNI(calledFrom = "gecko") + private static int getScreenAngle() { + return GeckoScreenOrientation.getInstance().getAngle(); + } + + @WrapForJNI(calledFrom = "gecko") + private static void notifyWakeLockChanged(final String topic, final String state) { + final int intState; + if ("unlocked".equals(state)) { + intState = WAKE_LOCK_STATE_UNLOCKED; + } else if ("locked-foreground".equals(state)) { + intState = WAKE_LOCK_STATE_LOCKED_FOREGROUND; + } else if ("locked-background".equals(state)) { + intState = WAKE_LOCK_STATE_LOCKED_BACKGROUND; + } else { + throw new IllegalArgumentException(); + } + setWakeLockState(topic, intState); + } + + @WrapForJNI(calledFrom = "gecko") + private static String getProxyForURI( + final String spec, final String scheme, final String host, final int port) { + final ProxySelector ps = new ProxySelector(); + + final 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(calledFrom = "gecko") + private static int getMaxTouchPoints() { + final 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; + } + + /* + * Keep in sync with PointerCapabilities in ServoTypes.h + */ + private static final int NO_POINTER = 0x00000000; + private static final int COARSE_POINTER = 0x00000001; + private static final int FINE_POINTER = 0x00000002; + private static final int HOVER_CAPABLE_POINTER = 0x00000004; + + private static int getPointerCapabilities(final InputDevice inputDevice) { + int result = NO_POINTER; + final int sources = inputDevice.getSources(); + + // Blink checks fine pointer at first, then it check coarse pointer. + // So, we should use same order for compatibility. + // Also, if using Chrome OS, source may be SOURCE_MOUSE | SOURCE_TOUCHSCREEN | SOURCE_STYLUS + // even if no touch screen. So we shouldn't check TOUCHSCREEN at first. + + if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE) + || hasInputDeviceSource(sources, InputDevice.SOURCE_STYLUS) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL)) { + result |= FINE_POINTER; + } else if (hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHSCREEN) + || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) { + result |= COARSE_POINTER; + } + + if (hasInputDeviceSource(sources, InputDevice.SOURCE_MOUSE) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TOUCHPAD) + || hasInputDeviceSource(sources, InputDevice.SOURCE_TRACKBALL) + || hasInputDeviceSource(sources, InputDevice.SOURCE_JOYSTICK)) { + result |= HOVER_CAPABLE_POINTER; + } + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + // For any-pointer and any-hover media queries features. + private static int getAllPointerCapabilities() { + int result = NO_POINTER; + + for (final int deviceId : InputDevice.getDeviceIds()) { + final InputDevice inputDevice = InputDevice.getDevice(deviceId); + if (inputDevice == null || !InputDeviceUtils.isPointerTypeDevice(inputDevice)) { + continue; + } + + result |= getPointerCapabilities(inputDevice); + } + + return result; + } + + private static boolean hasInputDeviceSource(final int sources, final int inputDeviceSource) { + return (sources & inputDeviceSource) == inputDeviceSource; + } + + public static synchronized void setScreenSizeOverride(final Rect size) { + sScreenSizeOverride = size; + } + + static final ScreenCompat sScreenCompat; + + private interface ScreenCompat { + Rect getScreenSize(); + + int getRotation(); + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN) + private static class JellyBeanScreenCompat implements ScreenCompat { + public Rect getScreenSize() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Display disp = wm.getDefaultDisplay(); + return new Rect(0, 0, disp.getWidth(), disp.getHeight()); + } + + public int getRotation() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + return wm.getDefaultDisplay().getRotation(); + } + } + + @TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) + private static class JellyBeanMR1ScreenCompat implements ScreenCompat { + public Rect getScreenSize() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + final Display disp = wm.getDefaultDisplay(); + final Point size = new Point(); + disp.getRealSize(size); + return new Rect(0, 0, size.x, size.y); + } + + public int getRotation() { + final WindowManager wm = + (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE); + return wm.getDefaultDisplay().getRotation(); + } + } + + @TargetApi(Build.VERSION_CODES.S) + private static class AndroidSScreenCompat implements ScreenCompat { + @SuppressLint("StaticFieldLeak") + private static Context sWindowContext; + + private static Context getWindowContext() { + if (sWindowContext == null) { + final DisplayManager displayManager = + (DisplayManager) getApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + final Display display = displayManager.getDisplay(Display.DEFAULT_DISPLAY); + sWindowContext = + getApplicationContext() + .createWindowContext(display, WindowManager.LayoutParams.TYPE_APPLICATION, null); + } + return sWindowContext; + } + + public Rect getScreenSize() { + final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class); + return windowManager.getCurrentWindowMetrics().getBounds(); + } + + public int getRotation() { + final WindowManager windowManager = getWindowContext().getSystemService(WindowManager.class); + return windowManager.getDefaultDisplay().getRotation(); + } + } + + static { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + sScreenCompat = new AndroidSScreenCompat(); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + sScreenCompat = new JellyBeanMR1ScreenCompat(); + } else { + sScreenCompat = new JellyBeanScreenCompat(); + } + } + + /* package */ static Rect getScreenSizeIgnoreOverride() { + return sScreenCompat.getScreenSize(); + } + + @WrapForJNI(calledFrom = "gecko") + private static synchronized Rect getScreenSize() { + if (sScreenSizeOverride != null) { + return sScreenSizeOverride; + } + + return getScreenSizeIgnoreOverride(); + } + + @WrapForJNI(calledFrom = "any") + public static int getAudioOutputFramesPerBuffer() { + final int DEFAULT = 512; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return DEFAULT; + } + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return DEFAULT; + } + final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_FRAMES_PER_BUFFER); + if (prop == null) { + return DEFAULT; + } + return Integer.parseInt(prop); + } + + @WrapForJNI(calledFrom = "any") + public static int getAudioOutputSampleRate() { + final int DEFAULT = 44100; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return DEFAULT; + } + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return DEFAULT; + } + final String prop = am.getProperty(AudioManager.PROPERTY_OUTPUT_SAMPLE_RATE); + if (prop == null) { + return DEFAULT; + } + return Integer.parseInt(prop); + } + + @WrapForJNI(calledFrom = "any") + public static void setCommunicationAudioModeOn(final boolean on) { + final AudioManager am = + (AudioManager) getApplicationContext().getSystemService(Context.AUDIO_SERVICE); + if (am == null) { + return; + } + + try { + if (on) { + Log.e(LOGTAG, "Setting communication mode ON"); + // This shouldn't throw, but does throw NullPointerException on a very + // small number of devices. + am.startBluetoothSco(); + am.setBluetoothScoOn(true); + } else { + Log.e(LOGTAG, "Setting communication mode OFF"); + am.stopBluetoothSco(); + am.setBluetoothScoOn(false); + } + } catch (final SecurityException | NullPointerException e) { + Log.e(LOGTAG, "could not set communication mode", e); + } + } + + private static String getLanguageTag(final Locale locale) { + final StringBuilder out = new StringBuilder(locale.getLanguage()); + final String country = locale.getCountry(); + final String variant = locale.getVariant(); + if (!TextUtils.isEmpty(country)) { + out.append('-').append(country); + } + if (!TextUtils.isEmpty(variant)) { + out.append('-').append(variant); + } + // e.g. "en", "en-US", or "en-US-POSIX". + return out.toString(); + } + + @WrapForJNI + public static String[] getDefaultLocales() { + // XXX We may have to convert some language codes such as "id" vs "in". + if (Build.VERSION.SDK_INT >= 24) { + final LocaleList localeList = LocaleList.getDefault(); + final String[] locales = new String[localeList.size()]; + for (int i = 0; i < localeList.size(); i++) { + locales[i] = localeList.get(i).toLanguageTag(); + } + return locales; + } + final String[] locales = new String[1]; + final Locale locale = Locale.getDefault(); + if (Build.VERSION.SDK_INT >= 21) { + locales[0] = locale.toLanguageTag(); + return locales; + } + + locales[0] = getLanguageTag(locale); + return locales; + } + + public static void setIs24HourFormat(final Boolean is24HourFormat) { + sIs24HourFormat = is24HourFormat; + } + + @WrapForJNI + public static boolean getIs24HourFormat() { + return sIs24HourFormat; + } + + @WrapForJNI + public static String getAppName() { + final Context context = getApplicationContext(); + final ApplicationInfo info = context.getApplicationInfo(); + final int id = info.labelRes; + return id == 0 ? info.nonLocalizedLabel.toString() : context.getString(id); + } + + @WrapForJNI + public static native boolean isParentProcess(); + + /** + * Returns a GeckoResult that will be completed to true if the GPU process is enabled and false if + * it is disabled. + */ + @WrapForJNI + public static native GeckoResult<Boolean> isGpuProcessEnabled(); +} 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 0000000000..19f489b399 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoBatteryManager.java @@ -0,0 +1,200 @@ +/* -*- 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 static final double kDefaultLevel = 1.0; + private static final boolean kDefaultCharging = true; + private static final double kDefaultRemainingTime = 0.0; + private static final 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(final Context context, final Intent intent) { + if (!intent.getAction().equals(Intent.ACTION_BATTERY_CHANGED)) { + Log.e(LOGTAG, "Got an unexpected intent!"); + return; + } + + final boolean previousCharging = isCharging(); + final 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")) { + final 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. + final double current = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1); + final 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. + final long currentTime = SystemClock.elapsedRealtime(); + final long dt = (currentTime - sLastLevelChange) / 1000; + final 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/GeckoEditableChild.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java new file mode 100644 index 0000000000..8a76548c1d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoEditableChild.java @@ -0,0 +1,456 @@ +/* -*- 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.graphics.RectF; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.view.KeyEvent; +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * GeckoEditableChild implements the Gecko-facing side of IME operation. Each nsWindow in the main + * process and each PuppetWidget in each child content process has an instance of + * GeckoEditableChild, which communicates with the GeckoEditableParent instance in the main process. + */ +public final class GeckoEditableChild extends JNIObject implements IGeckoEditableChild { + + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoEditableChild"; + + private static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9; + + private final class RemoteChild extends IGeckoEditableChild.Stub { + @Override // IGeckoEditableChild + public void transferParent(final IGeckoEditableParent editableParent) { + GeckoEditableChild.this.transferParent(editableParent); + } + + @Override // IGeckoEditableChild + public void onKeyEvent( + final int action, + final int keyCode, + final int scanCode, + final int metaState, + final int keyPressMetaState, + final long time, + final int domPrintableKeyValue, + final int repeatCount, + final int flags, + final boolean isSynthesizedImeKey, + final KeyEvent event) { + GeckoEditableChild.this.onKeyEvent( + action, + keyCode, + scanCode, + metaState, + keyPressMetaState, + time, + domPrintableKeyValue, + repeatCount, + flags, + isSynthesizedImeKey, + event); + } + + @Override // IGeckoEditableChild + public void onImeSynchronize() { + GeckoEditableChild.this.onImeSynchronize(); + } + + @Override // IGeckoEditableChild + public void onImeReplaceText(final int start, final int end, final String text) { + GeckoEditableChild.this.onImeReplaceText(start, end, text); + } + + @Override // IGeckoEditableChild + public void onImeInsertImage(final byte[] data, final String mimeType) { + GeckoEditableChild.this.onImeInsertImage(data, mimeType); + } + + @Override // IGeckoEditableChild + public void onImeAddCompositionRange( + final int start, + final int end, + final int rangeType, + final int rangeStyles, + final int rangeLineStyle, + final boolean rangeBoldLine, + final int rangeForeColor, + final int rangeBackColor, + final int rangeLineColor) { + GeckoEditableChild.this.onImeAddCompositionRange( + start, + end, + rangeType, + rangeStyles, + rangeLineStyle, + rangeBoldLine, + rangeForeColor, + rangeBackColor, + rangeLineColor); + } + + @Override // IGeckoEditableChild + public void onImeUpdateComposition(final int start, final int end, final int flags) { + GeckoEditableChild.this.onImeUpdateComposition(start, end, flags); + } + + @Override // IGeckoEditableChild + public void onImeRequestCursorUpdates(final int requestMode) { + GeckoEditableChild.this.onImeRequestCursorUpdates(requestMode); + } + + @Override // IGeckoEditableChild + public void onImeRequestCommit() { + GeckoEditableChild.this.onImeRequestCommit(); + } + } + + private final IGeckoEditableChild mEditableChild; + private final boolean mIsDefault; + + private IGeckoEditableParent mEditableParent; + private int mCurrentTextLength; // Used by Gecko thread + + @WrapForJNI(calledFrom = "gecko") + private GeckoEditableChild( + @Nullable final IGeckoEditableParent editableParent, final boolean isDefault) { + mIsDefault = isDefault; + + if (editableParent != null + && editableParent.asBinder().queryLocalInterface(IGeckoEditableParent.class.getName()) + != null) { + // IGeckoEditableParent is local; i.e. we're in the main process. + mEditableChild = this; + } else { + // IGeckoEditableParent is remote; i.e. we're in a content process. + mEditableChild = new RemoteChild(); + } + + if (editableParent != null) { + setParent(editableParent); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void setParent(final IGeckoEditableParent editableParent) { + mEditableParent = editableParent; + + if (mIsDefault) { + // Tell the parent we're the default child. + try { + editableParent.setDefaultChild(mEditableChild); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Failed to set default child", e); + } + } + } + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void transferParent(IGeckoEditableParent editableParent); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onKeyEvent( + int action, + int keyCode, + int scanCode, + int metaState, + int keyPressMetaState, + long time, + int domPrintableKeyValue, + int repeatCount, + int flags, + boolean isSynthesizedImeKey, + KeyEvent event); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeSynchronize(); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeReplaceText(int start, int end, String text); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeAddCompositionRange( + int start, + int end, + int rangeType, + int rangeStyles, + int rangeLineStyle, + boolean rangeBoldLine, + int rangeForeColor, + int rangeBackColor, + int rangeLineColor); + + // Don't update to the new composition if it's different than the current composition. + @WrapForJNI public static final int FLAG_KEEP_CURRENT_COMPOSITION = 1; + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeUpdateComposition(int start, int end, int flags); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeRequestCursorUpdates(int requestMode); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeRequestCommit(); + + @WrapForJNI(dispatchTo = "proxy") + @Override // IGeckoEditableChild + public native void onImeInsertImage(byte[] data, String mimeType); + + @Override // JNIObject + protected void disposeNative() { + // Disposal happens in native code. + throw new UnsupportedOperationException(); + } + + @WrapForJNI(calledFrom = "gecko") + private boolean hasEditableParent() { + if (mEditableParent != null) { + return true; + } + Log.w(LOGTAG, "No editable parent"); + return false; + } + + @Override // IInterface + public IBinder asBinder() { + // Return the GeckoEditableParent's binder as fallback for comparison purposes. + return mEditableChild != this + ? mEditableChild.asBinder() + : hasEditableParent() ? mEditableParent.asBinder() : null; + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIME(final int type) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "notifyIME(" + type + ")"); + } + if (!hasEditableParent()) { + return; + } + if (type == NOTIFY_IME_TO_CANCEL_COMPOSITION) { + // Composition should have been canceled on the parent side through text + // update notifications. We cannot verify that here because we don't + // keep track of spans on the child side, but it's simple to add the + // check to the parent side if ever needed. + return; + } + + try { + mEditableParent.notifyIME(mEditableChild, type); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + return; + } + } + + @WrapForJNI(calledFrom = "gecko") + private void notifyIMEContext( + final int state, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + final int flags) { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("notifyIMEContext("); + sb.append(state) + .append(", \"") + .append(typeHint) + .append("\", \"") + .append(modeHint) + .append("\", \"") + .append(actionHint) + .append("\", \"") + .append(autocapitalize) + .append("\", 0x") + .append(Integer.toHexString(flags)) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.notifyIMEContext( + mEditableChild.asBinder(), state, typeHint, modeHint, actionHint, autocapitalize, flags); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onSelectionChange( + final int start, final int end, final boolean causedOnlyByComposition) + throws RemoteException { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("onSelectionChange("); + sb.append(start) + .append(", ") + .append(end) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + final int currentLength = mCurrentTextLength; + 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"); + } + + mEditableParent.onSelectionChange( + mEditableChild.asBinder(), start, end, causedOnlyByComposition); + } + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "ignore") + private void onTextChange( + final CharSequence text, + final int start, + final int unboundedOldEnd, + final int unboundedNewEnd, + final boolean causedOnlyByComposition) + throws RemoteException { + if (DEBUG) { + ThreadUtils.assertOnGeckoThread(); + final StringBuilder sb = new StringBuilder("onTextChange("); + sb.append(text) + .append(", ") + .append(start) + .append(", ") + .append(unboundedOldEnd) + .append(", ") + .append(unboundedNewEnd) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + if (!hasEditableParent()) { + return; + } + + if (start < 0 || start > unboundedOldEnd) { + Log.e(LOGTAG, "invalid text notification range: " + start + " to " + unboundedOldEnd); + throw new IllegalArgumentException("invalid text notification range"); + } + + /* For the "end" parameters, Gecko can pass in a large + number to denote "end of the text". Fix that here */ + final int currentLength = mCurrentTextLength; + 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"); + } + + mCurrentTextLength += start + text.length() - oldEnd; + // Need unboundedOldEnd so GeckoEditable can distinguish changed text vs cleared text. + if (text.length() == 0) { + // Remove text in range. + mEditableParent.onTextChange( + mEditableChild.asBinder(), text, start, unboundedOldEnd, causedOnlyByComposition); + return; + } + // Using large text causes TransactionTooLargeException, so split text data. + int offset = 0; + int newUnboundedOldEnd = unboundedOldEnd; + while (offset < text.length()) { + final int end = Math.min(offset + 1024 * 64 /* 64KB */, text.length()); + mEditableParent.onTextChange( + mEditableChild.asBinder(), + text.subSequence(offset, end), + start + offset, + newUnboundedOldEnd, + causedOnlyByComposition); + offset = end; + newUnboundedOldEnd = start + offset; + } + } + + @WrapForJNI(calledFrom = "gecko") + private void onDefaultKeyEvent(final KeyEvent event) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + final 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()); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.onDefaultKeyEvent(mEditableChild.asBinder(), event); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void updateCompositionRects(final RectF[] rects, final RectF caretRect) { + if (DEBUG) { + // GeckoEditableListener methods should all be called from the Gecko thread + ThreadUtils.assertOnGeckoThread(); + Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")"); + } + if (!hasEditableParent()) { + return; + } + + try { + mEditableParent.updateCompositionRects(mEditableChild.asBinder(), rects, caretRect); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java new file mode 100644 index 0000000000..c81e1678c7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoJavaSampler.java @@ -0,0 +1,801 @@ +/* -*- 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.Build; +import android.os.Looper; +import android.os.Process; +import android.os.SystemClock; +import android.util.Log; +import androidx.annotation.GuardedBy; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Queue; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.GeckoResult; + +/** + * Takes samples and adds markers for Java threads for the Gecko profiler. + * + * <p>This class is thread safe because it uses synchronized on accesses to its mutable state. One + * exception is {@link #isProfilerActive()}: see the javadoc for details. + */ +public class GeckoJavaSampler { + private static final String LOGTAG = "GeckoJavaSampler"; + + /** + * The thread ID to use for the main thread instead of its true thread ID. + * + * <p>The main thread is sampled twice: once for native code and once on the JVM. The native + * version uses the thread's id so we replace it to avoid a collision. We use this thread ID + * because it's unlikely any other thread currently has it. We can't use 0 because 0 is considered + * "unspecified" in native code: + * https://searchfox.org/mozilla-central/rev/d4ebb53e719b913afdbcf7c00e162f0e96574701/mozglue/baseprofiler/public/BaseProfilerUtils.h#194 + */ + private static final long REPLACEMENT_MAIN_THREAD_ID = 1; + /** + * The thread name to use for the main thread instead of its true thread name. The name is "main", + * which is ambiguous with the JS main thread, so we rename it to match the C++ replacement. We + * expect our code to later add a suffix to avoid a collision with the C++ thread name. See {@link + * #REPLACEMENT_MAIN_THREAD_ID} for related details. + */ + private static final String REPLACEMENT_MAIN_THREAD_NAME = "AndroidUI"; + + @GuardedBy("GeckoJavaSampler.class") + private static SamplingRunnable sSamplingRunnable; + + @GuardedBy("GeckoJavaSampler.class") + private static ScheduledExecutorService sSamplingScheduler; + + // See isProfilerActive for details on the AtomicReference. + @GuardedBy("GeckoJavaSampler.class") + private static final AtomicReference<ScheduledFuture<?>> sSamplingFuture = + new AtomicReference<>(); + + private static final MarkerStorage sMarkerStorage = new MarkerStorage(); + + /** + * Returns true if profiler is running and unpaused at the moment which means it's allowed to add + * a marker. + * + * <p>Thread policy: we want this method to be inexpensive (i.e. non-blocking) because we want to + * be able to use it in performance-sensitive code. That's why we rely on an AtomicReference. If + * this requirement didn't exist, the AtomicReference could be removed because the class thread + * policy is to call synchronized on mutable state access. + */ + public static boolean isProfilerActive() { + // This value will only be present if the profiler is started and not paused. + return sSamplingFuture.get() != null; + } + + // Use the same timer primitive as the profiler + // to get a perfect sample syncing. + @WrapForJNI + private static native double getProfilerTime(); + + /** Try to get the profiler time. Returns null if profiler is not running. */ + public static @Nullable Double tryToGetProfilerTime() { + if (!isProfilerActive()) { + // Android profiler hasn't started yet. + return null; + } + if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) { + // getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + return null; + } + + return getProfilerTime(); + } + + /** + * A data container for a profiler sample. This class is effectively immutable (i.e. technically + * mutable but never mutated after construction) so is thread safe *if it is safely published* + * (see Java Concurrency in Practice, 2nd Ed., Section 3.5.3 for safe publication idioms). + */ + private static class Sample { + public final long mThreadId; + public final Frame[] mFrames; + public final double mTime; + public final long mJavaTime; // non-zero if Android system time is used + + public Sample(final long aThreadId, final StackTraceElement[] aStack) { + mThreadId = aThreadId; + mFrames = new Frame[aStack.length]; + mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + + for (int i = 0; i < aStack.length; i++) { + mFrames[aStack.length - 1 - i] = + new Frame(aStack[i].getMethodName(), aStack[i].getClassName()); + } + } + } + + /** + * A container for the metadata around a call in a stack. This class is thread safe by being + * immutable. + */ + private static class Frame { + public final String methodName; + public final String className; + + private Frame(final String methodName, final String className) { + this.methodName = methodName; + this.className = className; + } + } + + /** A data container for thread metadata. */ + private static class ThreadInfo { + private final long mId; + private final String mName; + + public ThreadInfo(final long mId, final String mName) { + this.mId = mId; + this.mName = mName; + } + + @WrapForJNI + public long getId() { + return mId; + } + + @WrapForJNI + public String getName() { + return mName; + } + } + + /** + * A data container for metadata around a marker. This class is thread safe by being immutable. + */ + private static class Marker extends JNIObject { + /** The id of the thread this marker was captured on. */ + private final long mThreadId; + + /** Name of the marker */ + private final String mMarkerName; + /** Either start time for the duration markers or time for a point-in-time markers. */ + private final double mTime; + /** + * A fallback field of {@link #mTime} but it only exists when {@link #getProfilerTime()} is + * failed. It is non-zero if Android time is used. + */ + private final long mJavaTime; + /** End time for the duration markers. It's zero for point-in-time markers. */ + private final double mEndTime; + /** + * A fallback field of {@link #mEndTime} but it only exists when {@link #getProfilerTime()} is + * failed. It is non-zero if Android time is used. + */ + private final long mEndJavaTime; + /** A nullable additional information field for the marker. */ + private @Nullable final String mText; + + /** + * Constructor for the Marker class. It initializes different kinds of markers depending on the + * parameters. Here are some combinations to create different kinds of markers: + * + * <p>If you want to create a marker that points a single point in time: <code> + * new Marker("name", null, null, null)</code> to implicitly get the time when this marker is + * added, or <code>new Marker("name", null, endTime, null)</code> to use an explicit time as an + * end time retrieved from {@link #tryToGetProfilerTime()}. + * + * <p>If you want to create a marker that has a start and end time: <code> + * new Marker("name", startTime, null, null)</code> to implicitly get the end time when this + * marker is added, or <code>new Marker("name", startTime, endTime, null)</code> to explicitly + * give the marker start and end time retrieved from {@link #tryToGetProfilerTime()}. + * + * <p>Last parameter is optional and can be given with any combination. This gives users the + * ability to add more context into a marker. + * + * @param aThreadId The id of the thread this marker was captured on. + * @param aMarkerName Identifier of the marker as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time. + * @param aText An optional string field for more information about the marker. + */ + public Marker( + final long aThreadId, + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + mThreadId = getAdjustedThreadId(aThreadId); + mMarkerName = aMarkerName; + mText = aText; + + if (aStartTime != null) { + // Start time is provided. This is an interval marker. + mTime = aStartTime; + mJavaTime = 0; + if (aEndTime != null) { + // End time is also provided. + mEndTime = aEndTime; + mEndJavaTime = 0; + } else { + // End time is not provided. Get the profiler time now and use it. + mEndTime = + GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mEndTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mEndJavaTime = mEndTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + } + + } else { + // Start time is not provided. This is point-in-time marker. + mEndTime = 0; + mEndJavaTime = 0; + + if (aEndTime != null) { + // End time is also provided. Use that to point the time. + mTime = aEndTime; + mJavaTime = 0; + } else { + mTime = GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY) ? getProfilerTime() : 0; + + // if mTime == 0, getProfilerTime is not available yet; either libs are not loaded, + // or profiling hasn't started on the Gecko side yet + mJavaTime = mTime == 0.0d ? SystemClock.elapsedRealtime() : 0; + } + } + } + + @WrapForJNI + @Override // JNIObject + protected native void disposeNative(); + + @WrapForJNI + public double getStartTime() { + if (mJavaTime != 0) { + return (mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return mTime; + } + + @WrapForJNI + public double getEndTime() { + if (mEndJavaTime != 0) { + return (mEndJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return mEndTime; + } + + @WrapForJNI + public long getThreadId() { + return mThreadId; + } + + @WrapForJNI + public @NonNull String getMarkerName() { + return mMarkerName; + } + + @WrapForJNI + public @Nullable String getMarkerText() { + return mText; + } + } + + /** + * Public method to add a new marker to Gecko profiler. This can be used to add a marker *inside* + * the geckoview code, but ideally ProfilerController methods should be used instead. + * + * @see Marker#Marker(long, String, Double, Double, String) for information about the parameter + * options. + */ + public static void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + sMarkerStorage.addMarker(aMarkerName, aStartTime, aEndTime, aText); + } + + /** + * A routine to store profiler samples. This class is thread safe because it synchronizes access + * to its mutable state. + */ + private static class SamplingRunnable implements Runnable { + private final long mMainThreadId = Looper.getMainLooper().getThread().getId(); + + // Sampling interval that is used by start and unpause + public final int mInterval; + private final int mSampleCount; + + @GuardedBy("GeckoJavaSampler.class") + private boolean mBufferOverflowed = false; + + @GuardedBy("GeckoJavaSampler.class") + private @NonNull final List<Thread> mThreadsToProfile; + + @GuardedBy("GeckoJavaSampler.class") + private final Sample[] mSamples; + + @GuardedBy("GeckoJavaSampler.class") + private int mSamplePos; + + public SamplingRunnable( + @NonNull final List<Thread> aThreadsToProfile, + final int aInterval, + final int aSampleCount) { + mThreadsToProfile = aThreadsToProfile; + // Sanity check of sampling interval. + mInterval = Math.max(1, aInterval); + mSampleCount = aSampleCount; + mSamples = new Sample[mSampleCount]; + mSamplePos = 0; + } + + @Override + public void run() { + synchronized (GeckoJavaSampler.class) { + // To minimize allocation in the critical section, we use a traditional for loop instead of + // a for each (i.e. `elem : coll`) loop because that allocates an iterator. + // + // We won't capture threads that are started during profiling because we iterate through an + // unchanging list of threads (bug 1759550). + for (int i = 0; i < mThreadsToProfile.size(); i++) { + final Thread thread = mThreadsToProfile.get(i); + + // getStackTrace will return an empty trace if the thread is not alive: we call continue + // to avoid wasting space in the buffer for an empty sample. + final StackTraceElement[] stackTrace = thread.getStackTrace(); + if (stackTrace.length == 0) { + continue; + } + + mSamples[mSamplePos] = new Sample(thread.getId(), stackTrace); + mSamplePos += 1; + if (mSamplePos == mSampleCount) { + // Sample array is full now, go back to start of + // the array and override old samples + mSamplePos = 0; + mBufferOverflowed = true; + } + } + } + } + + private Sample getSample(final int aSampleId) { + synchronized (GeckoJavaSampler.class) { + if (aSampleId >= mSampleCount) { + // Return early because there is no more sample left. + return null; + } + + int samplePos = aSampleId; + if (mBufferOverflowed) { + // This is a circular buffer and the buffer is overflowed. Start + // of the buffer is mSamplePos now. Calculate the real index. + samplePos = (samplePos + mSamplePos) % mSampleCount; + } + + // Since the array elements are initialized to null, it will return + // null whenever we access to an element that's not been written yet. + // We want it to return null in that case, so it's okay. + return mSamples[samplePos]; + } + } + } + + /** + * Returns the sample with the given sample ID. + * + * <p>Thread safety code smell: this method call is synchronized but this class returns a + * reference to an effectively immutable object so that the reference is accessible after + * synchronization ends. It's unclear if this is thread safe. However, this is safe with the + * current callers (because they are all synchronized and don't leak the Sample) so we don't + * investigate it further. + */ + private static synchronized Sample getSample(final int aSampleId) { + return sSamplingRunnable.getSample(aSampleId); + } + + @WrapForJNI + public static Marker pollNextMarker() { + return sMarkerStorage.pollNextMarker(); + } + + @WrapForJNI + public static synchronized int getRegisteredThreadCount() { + return sSamplingRunnable.mThreadsToProfile.size(); + } + + @WrapForJNI + public static synchronized ThreadInfo getRegisteredThreadInfo(final int aIndex) { + final Thread thread = sSamplingRunnable.mThreadsToProfile.get(aIndex); + + // See REPLACEMENT_MAIN_THREAD_NAME for why we do this. + String adjustedThreadName = + thread.getId() == sSamplingRunnable.mMainThreadId + ? REPLACEMENT_MAIN_THREAD_NAME + : thread.getName(); + + // To distinguish JVM threads from native threads, we append a JVM-specific suffix. + adjustedThreadName += " (JVM)"; + return new ThreadInfo(getAdjustedThreadId(thread.getId()), adjustedThreadName); + } + + @WrapForJNI + public static synchronized long getThreadId(final int aSampleId) { + final Sample sample = getSample(aSampleId); + return getAdjustedThreadId(sample != null ? sample.mThreadId : 0); + } + + private static synchronized long getAdjustedThreadId(final long threadId) { + // See REPLACEMENT_MAIN_THREAD_ID for why we do this. + return threadId == sSamplingRunnable.mMainThreadId ? REPLACEMENT_MAIN_THREAD_ID : threadId; + } + + @WrapForJNI + public static synchronized double getSampleTime(final int aSampleId) { + final Sample sample = getSample(aSampleId); + if (sample != null) { + if (sample.mJavaTime != 0) { + return (sample.mJavaTime - SystemClock.elapsedRealtime()) + getProfilerTime(); + } + return sample.mTime; + } + return 0; + } + + @WrapForJNI + public static synchronized String getFrameName(final int aSampleId, final int aFrameId) { + final Sample sample = getSample(aSampleId); + if (sample != null && aFrameId < sample.mFrames.length) { + final Frame frame = sample.mFrames[aFrameId]; + if (frame == null) { + return null; + } + return frame.className + "." + frame.methodName + "()"; + } + return null; + } + + /** + * A start/stop-aware container for storing profiler markers. + * + * <p>This class is thread safe: see {@link #mMarkers} and other member variables for the + * threading policy. Start/stop are guaranteed to execute in the order they are called but other + * methods do not have such ordering guarantees. + */ + private static class MarkerStorage { + /** + * The underlying storage for the markers. This field maintains thread safety without using + * synchronized everywhere by: + * <li>- using volatile to allow non-blocking reads + * <li>- leveraging a thread safe collection when accessing the underlying data + * <li>- looping until success for compound read-write operations + */ + private volatile Queue<Marker> mMarkers; + + /** + * The thread ids of the threads we're profiling. This field maintains thread safety by writing + * a read-only value to this volatile field before concurrency begins and only reading it during + * concurrent sections. + */ + private volatile Set<Long> mProfiledThreadIds = Collections.emptySet(); + + MarkerStorage() {} + + public synchronized void start(final int aMarkerCount, final List<Thread> aProfiledThreads) { + if (this.mMarkers != null) { + return; + } + this.mMarkers = new LinkedBlockingQueue<>(aMarkerCount); + + final Set<Long> profiledThreadIds = new HashSet<>(aProfiledThreads.size()); + for (final Thread thread : aProfiledThreads) { + profiledThreadIds.add(thread.getId()); + } + + // We use a temporary collection, rather than mutating the collection within the member + // variable, to ensure the collection is fully written before the state is made available to + // all threads via the volatile write into the member variable. This collection must be + // read-only for it to remain thread safe. + mProfiledThreadIds = Collections.unmodifiableSet(profiledThreadIds); + } + + public synchronized void stop() { + if (this.mMarkers == null) { + return; + } + this.mMarkers = null; + mProfiledThreadIds = Collections.emptySet(); + } + + private void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + final Queue<Marker> markersQueue = this.mMarkers; + if (markersQueue == null) { + // Profiler is not active. + return; + } + + final long threadId = Thread.currentThread().getId(); + if (!mProfiledThreadIds.contains(threadId)) { + return; + } + + final Marker newMarker = new Marker(threadId, aMarkerName, aStartTime, aEndTime, aText); + boolean successful = markersQueue.offer(newMarker); + while (!successful) { + // Marker storage is full, remove the head and add again. + markersQueue.poll(); + successful = markersQueue.offer(newMarker); + } + } + + private Marker pollNextMarker() { + final Queue<Marker> markersQueue = this.mMarkers; + if (markersQueue == null) { + // Profiler is not active. + return null; + } + // Retrieve and return the head of this queue. + // Returns null if the queue is empty. + return markersQueue.poll(); + } + } + + @WrapForJNI + public static void start( + @NonNull final Object[] aFilters, final int aInterval, final int aEntryCount) { + synchronized (GeckoJavaSampler.class) { + if (sSamplingRunnable != null) { + return; + } + + final ScheduledFuture<?> future = sSamplingFuture.get(); + if (future != null && !future.isDone()) { + return; + } + + Log.i(LOGTAG, "Profiler starting. Calling thread: " + Thread.currentThread().getName()); + + // Setting a limit of 120000 (2 mins with 1ms interval) for samples and markers for now + // to make sure we are not allocating too much. + final int limitedEntryCount = Math.min(aEntryCount, 120000); + + final List<Thread> threadsToProfile = getThreadsToProfile(aFilters); + if (threadsToProfile.size() < 1) { + throw new IllegalStateException("Expected >= 1 thread to profile (main thread)."); + } + Log.i(LOGTAG, "Number of threads to profile: " + threadsToProfile.size()); + + sSamplingRunnable = new SamplingRunnable(threadsToProfile, aInterval, limitedEntryCount); + sMarkerStorage.start(limitedEntryCount, threadsToProfile); + sSamplingScheduler = Executors.newSingleThreadScheduledExecutor(); + sSamplingFuture.set( + sSamplingScheduler.scheduleAtFixedRate( + sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS)); + } + } + + private static @NonNull List<Thread> getThreadsToProfile(final Object[] aFilters) { + // Clean up filters. + final List<String> cleanedFilters = new ArrayList<>(); + for (final Object rawFilter : aFilters) { + // aFilters is a String[] but jni can only accept Object[] so we're forced to cast. + // + // We could pass the lowercased filters from native code but it may not handle lowercasing the + // same way Java does so we lower case here so it's consistent later when we lower case the + // thread name and compare against it. + final String filter = ((String) rawFilter).trim().toLowerCase(Locale.US); + + // If the filter is empty, it's not meaningful: skip. + if (filter.isEmpty()) { + continue; + } + + cleanedFilters.add(filter); + } + + final ThreadGroup rootThreadGroup = getRootThreadGroup(); + final Thread[] activeThreads = getActiveThreads(rootThreadGroup); + final Thread mainThread = Looper.getMainLooper().getThread(); + + // We model these catch-all filters after the C++ code (which we should eventually deduplicate): + // https://searchfox.org/mozilla-central/rev/b0779bcc485dc1c04334dfb9ea024cbfff7b961a/tools/profiler/core/platform.cpp#778-801 + if (cleanedFilters.contains("*") || doAnyFiltersMatchPid(cleanedFilters, Process.myPid())) { + final List<Thread> activeThreadList = new ArrayList<>(); + Collections.addAll(activeThreadList, activeThreads); + if (!activeThreadList.contains(mainThread)) { + activeThreadList.add(mainThread); // see below for why this is necessary. + } + return activeThreadList; + } + + // We always want to profile the main thread. We're not certain getActiveThreads returns + // all active threads since we've observed that getActiveThreads doesn't include the main thread + // during xpcshell tests even though it's alive (bug 1760716). We intentionally don't rely on + // that method to add the main thread here. + final List<Thread> threadsToProfile = new ArrayList<>(); + threadsToProfile.add(mainThread); + + for (final Thread thread : activeThreads) { + if (shouldProfileThread(thread, cleanedFilters, mainThread)) { + threadsToProfile.add(thread); + } + } + return threadsToProfile; + } + + private static boolean shouldProfileThread( + final Thread aThread, final List<String> aFilters, final Thread aMainThread) { + final String threadName = aThread.getName().trim().toLowerCase(Locale.US); + if (threadName.isEmpty()) { + return false; // We can't match against a thread with no name: skip. + } + + if (aThread.equals(aMainThread)) { + return false; // We've already added the main thread outside of this method. + } + + for (final String filter : aFilters) { + // In order to generically support thread pools with thread names like "arch_disk_io_0" (the + // kotlin IO dispatcher), we check if the filter is inside the thread name (e.g. a filter of + // "io" will match all of the threads in that pool) rather than an equality check. + if (threadName.contains(filter)) { + return true; + } + } + + return false; + } + + private static boolean doAnyFiltersMatchPid( + @NonNull final List<String> aFilters, final long aPid) { + final String prefix = "pid:"; + for (final String filter : aFilters) { + if (!filter.startsWith(prefix)) { + continue; + } + + try { + final long filterPid = Long.parseLong(filter.substring(prefix.length())); + if (filterPid == aPid) { + return true; + } + } catch (final NumberFormatException e) { + /* do nothing. */ + } + } + + return false; + } + + private static @NonNull Thread[] getActiveThreads(final @NonNull ThreadGroup rootThreadGroup) { + // We need the root thread group to get all of the active threads because of how + // ThreadGroup.enumerate works. + // + // ThreadGroup.enumerate is inherently racey so we loop until we capture all of the active + // threads. We can only detect if we didn't capture all of the threads if the number of threads + // found (the value returned by enumerate) is smaller than the array we're capturing them in. + // Therefore, we make the array slightly larger than the known number of threads. + Thread[] allThreads; + int threadsFound; + do { + allThreads = new Thread[rootThreadGroup.activeCount() + 15]; + threadsFound = rootThreadGroup.enumerate(allThreads, /* recurse */ true); + } while (threadsFound >= allThreads.length); + + // There will be more indices in the array than threads and these will be set to null. We remove + // the null values to minimize bugs. + return Arrays.copyOfRange(allThreads, 0, threadsFound); + } + + private static @NonNull ThreadGroup getRootThreadGroup() { + // Assert non-null: getThreadGroup only returns null for dead threads but the current thread + // can't be dead. + ThreadGroup parentGroup = Objects.requireNonNull(Thread.currentThread().getThreadGroup()); + + ThreadGroup group = null; + while (parentGroup != null) { + group = parentGroup; + parentGroup = group.getParent(); + } + return group; + } + + @WrapForJNI + public static void pauseSampling() { + synchronized (GeckoJavaSampler.class) { + final ScheduledFuture<?> future = sSamplingFuture.getAndSet(null); + future.cancel(false /* mayInterruptIfRunning */); + } + } + + @WrapForJNI + public static void unpauseSampling() { + synchronized (GeckoJavaSampler.class) { + if (sSamplingFuture.get() != null) { + return; + } + sSamplingFuture.set( + sSamplingScheduler.scheduleAtFixedRate( + sSamplingRunnable, 0, sSamplingRunnable.mInterval, TimeUnit.MILLISECONDS)); + } + } + + @WrapForJNI + public static void stop() { + synchronized (GeckoJavaSampler.class) { + if (sSamplingRunnable == null) { + return; + } + + Log.i( + LOGTAG, + "Profiler stopping. Sample array position: " + + sSamplingRunnable.mSamplePos + + ". Overflowed? " + + sSamplingRunnable.mBufferOverflowed); + + try { + sSamplingScheduler.shutdown(); + // 1s is enough to wait shutdown. + sSamplingScheduler.awaitTermination(1000, TimeUnit.MILLISECONDS); + } catch (final InterruptedException e) { + Log.e(LOGTAG, "Sampling scheduler isn't terminated. Last sampling data might be broken."); + sSamplingScheduler.shutdownNow(); + } + sSamplingScheduler = null; + sSamplingRunnable = null; + sSamplingFuture.set(null); + sMarkerStorage.stop(); + } + } + + @WrapForJNI(dispatchTo = "gecko", stubName = "StartProfiler") + private static native void startProfilerNative(String[] aFilters, String[] aFeaturesArr); + + @WrapForJNI(dispatchTo = "gecko", stubName = "StopProfiler") + private static native void stopProfilerNative(GeckoResult<byte[]> aResult); + + public static void startProfiler(final String[] aFilters, final String[] aFeaturesArr) { + startProfilerNative(aFilters, aFeaturesArr); + } + + public static GeckoResult<byte[]> stopProfiler() { + final GeckoResult<byte[]> result = new GeckoResult<byte[]>(); + stopProfilerNative(result); + return result; + } + + /** Returns the device brand and model as a string. */ + @WrapForJNI + public static String getDeviceInformation() { + final StringBuilder sb = new StringBuilder(Build.BRAND); + sb.append(" "); + sb.append(Build.MODEL); + return sb.toString(); + } +} 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 0000000000..02ed848f6b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoNetworkManager.java @@ -0,0 +1,413 @@ +/* -*- 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.annotation.SuppressLint; +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.WifiManager; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.WrapForJNI; +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; + +/** + * Provides connection type, subtype and general network status (up/down). + * + * <p>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. + * + * <p>Specific mobile subtypes are mapped to general 2G, 3G and 4G buckets. + * + * <p>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 { + private static final String LOGTAG = "GeckoNetworkManager"; + + // If network configuration and/or status changed, we send details of what changed. + // If we received a "check out new network state!" intent from the OS but nothing in it looks + // different, we ignore it. See Bug 1330836 for some relevant details. + 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 mContext; + + 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 mCurrentState = ManagerState.OffNoListeners; + private ConnectionType mCurrentConnectionType = ConnectionType.NONE; + private ConnectionType mPreviousConnectionType = ConnectionType.NONE; + private ConnectionSubType mCurrentConnectionSubtype = ConnectionSubType.UNKNOWN; + private ConnectionSubType mPreviousConnectionSubtype = ConnectionSubType.UNKNOWN; + private NetworkStatus mCurrentNetworkStatus = NetworkStatus.UNKNOWN; + private NetworkStatus mPreviousNetworkStatus = NetworkStatus.UNKNOWN; + + private GeckoNetworkManager() {} + + private void onDestroy() { + handleManagerEvent(ManagerEvent.stop); + } + + public static GeckoNetworkManager getInstance() { + if (instance == null) { + instance = new GeckoNetworkManager(); + } + + return instance; + } + + public double[] getCurrentInformation() { + final Context applicationContext = GeckoAppShell.getApplicationContext(); + final ConnectionType connectionType = mCurrentConnectionType; + return new double[] { + connectionType.value, + connectionType == ConnectionType.WIFI ? 1.0 : 0.0, + connectionType == ConnectionType.WIFI ? wifiDhcpGatewayAddress(applicationContext) : 0.0 + }; + } + + @Override + public void onReceive(final Context aContext, final Intent aIntent) { + handleManagerEvent(ManagerEvent.receivedUpdate); + } + + public void start(final Context context) { + mContext = 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(final ManagerEvent event) { + final ManagerState nextState = getNextState(mCurrentState, event); + + Log.d(LOGTAG, "Incoming event " + event + " for state " + mCurrentState + " -> " + nextState); + if (nextState == null) { + Log.w(LOGTAG, "Invalid event " + event + " for state " + mCurrentState); + 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 (mContext != null) { + contextForAction = mContext; + } else { + contextForAction = GeckoAppShell.getApplicationContext(); + } + + if (contextForAction == null) { + Log.w( + LOGTAG, + "Context is not available while processing event " + + event + + " for state " + + mCurrentState); + return false; + } + + performActionsForStateEvent(contextForAction, mCurrentState, event); + mCurrentState = 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( + final @NonNull ManagerState currentState, final @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."); + } + mCurrentConnectionType = NetworkUtils.getConnectionType(connectivityManager); + mCurrentNetworkStatus = NetworkUtils.getNetworkStatus(connectivityManager); + mCurrentConnectionSubtype = NetworkUtils.getConnectionSubType(connectivityManager); + Log.d( + LOGTAG, + "New network state: " + + mCurrentNetworkStatus + + ", " + + mCurrentConnectionType + + ", " + + mCurrentConnectionSubtype); + } + + @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) { + final boolean connectionTypeOrSubtypeChanged = + mCurrentConnectionType != mPreviousConnectionType + || mCurrentConnectionSubtype != mPreviousConnectionSubtype; + if (connectionTypeOrSubtypeChanged) { + mPreviousConnectionType = mCurrentConnectionType; + mPreviousConnectionSubtype = mCurrentConnectionSubtype; + + final boolean isWifi = mCurrentConnectionType == ConnectionType.WIFI; + final int gateway = !isWifi ? 0 : wifiDhcpGatewayAddress(context); + + if (GeckoThread.isRunning()) { + onConnectionChanged( + mCurrentConnectionType.value, mCurrentConnectionSubtype.value, isWifi, gateway); + } else { + GeckoThread.queueNativeCall( + GeckoNetworkManager.class, + "onConnectionChanged", + mCurrentConnectionType.value, + String.class, + mCurrentConnectionSubtype.value, + isWifi, + gateway); + } + } + + // If neither network status nor network configuration changed, do nothing. + if (mCurrentNetworkStatus == mPreviousNetworkStatus && !connectionTypeOrSubtypeChanged) { + return; + } + + // If network status remains the same, send "changed". Otherwise, send new network status. + // See Bug 1330836 for relevant details. + final String status; + if (mCurrentNetworkStatus == mPreviousNetworkStatus) { + status = LINK_DATA_CHANGED; + } else { + mPreviousNetworkStatus = mCurrentNetworkStatus; + status = mCurrentNetworkStatus.value; + } + + 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 { + final WifiManager mgr = + (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (mgr == null) { + return 0; + } + + @SuppressLint("MissingPermission") + final DhcpInfo d = mgr.getDhcpInfo(); + if (d == null) { + return 0; + } + + return d.gateway; + + } catch (final 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; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java new file mode 100644 index 0000000000..3c84edbc96 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenChangeListener.java @@ -0,0 +1,71 @@ +/* -*- Mode: Java; c-basic-offset: 2; 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.annotation.TargetApi; +import android.content.Context; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.util.Log; +import android.view.Display; + +@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR1) +public class GeckoScreenChangeListener implements DisplayManager.DisplayListener { + private static final String LOGTAG = "ScreenChangeListener"; + private static final boolean DEBUG = false; + + public GeckoScreenChangeListener() {} + + @Override + public void onDisplayAdded(final int displayId) {} + + @Override + public void onDisplayRemoved(final int displayId) {} + + @Override + public void onDisplayChanged(final int displayId) { + if (DEBUG) { + Log.d(LOGTAG, "onDisplayChanged"); + } + + // Even if onDisplayChanged is called, Configuration may not updated yet. + // So we use Display's data instead. + if (displayId != Display.DEFAULT_DISPLAY) { + if (DEBUG) { + Log.d(LOGTAG, "Primary display is only supported"); + } + return; + } + + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + + GeckoScreenOrientation.getInstance().update(displayManager.getDisplay(displayId)); + } + + private static DisplayManager getDisplayManager() { + return (DisplayManager) + GeckoAppShell.getApplicationContext().getSystemService(Context.DISPLAY_SERVICE); + } + + public void enable() { + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + displayManager.registerDisplayListener(this, null); + } + + public void disable() { + final DisplayManager displayManager = getDisplayManager(); + if (displayManager == null) { + return; + } + displayManager.unregisterDisplayListener(this); + } +} 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 0000000000..bdb7b4b331 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoScreenOrientation.java @@ -0,0 +1,273 @@ +/* -*- 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 static android.content.res.Configuration.ORIENTATION_LANDSCAPE; +import static android.content.res.Configuration.ORIENTATION_PORTRAIT; + +import android.content.Context; +import android.graphics.Rect; +import android.util.Log; +import android.view.Display; +import android.view.Surface; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.util.ThreadUtils; + +/* + * 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 hal/HalScreenConfiguration.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), + ANY( + PORTRAIT_PRIMARY.value + | PORTRAIT_SECONDARY.value + | LANDSCAPE_PRIMARY.value + | LANDSCAPE_SECONDARY.value), + DEFAULT(1 << 4); + + public final short value; + + private ScreenOrientation(final int value) { + this.value = (short) value; + } + + private static final ScreenOrientation[] sValues = ScreenOrientation.values(); + + public static ScreenOrientation get(final int value) { + for (final ScreenOrientation orient : sValues) { + if (orient.value == value) { + return orient; + } + } + return NONE; + } + } + + // Singleton instance. + private static GeckoScreenOrientation sInstance; + // Default rotation, used when device rotation is unknown. + private static final int DEFAULT_ROTATION = Surface.ROTATION_0; + // Last updated screen orientation with Gecko value space. + private ScreenOrientation mScreenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + + public interface OrientationChangeListener { + void onScreenOrientationChanged(ScreenOrientation newOrientation); + } + + private final List<OrientationChangeListener> mListeners; + + public static GeckoScreenOrientation getInstance() { + if (sInstance == null) { + sInstance = new GeckoScreenOrientation(); + } + return sInstance; + } + + private GeckoScreenOrientation() { + mListeners = new ArrayList<>(); + update(); + } + + /** Add a listener that will be notified when the screen orientation has changed. */ + public void addListener(final OrientationChangeListener aListener) { + ThreadUtils.assertOnUiThread(); + mListeners.add(aListener); + } + + /** Remove a OrientationChangeListener again. */ + public void removeListener(final OrientationChangeListener aListener) { + ThreadUtils.assertOnUiThread(); + mListeners.remove(aListener); + } + + /* + * Update screen orientation. + * Retrieve orientation and rotation via GeckoAppShell. + * + * @return Whether the screen orientation has changed. + */ + public boolean update() { + // Check whether we have the application context for fenix/a-c unit test. + final Context appContext = GeckoAppShell.getApplicationContext(); + if (appContext == null) { + return false; + } + final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride(); + final int orientation = + rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT; + return update(getScreenOrientation(orientation, getRotation())); + } + + /* + * Update screen orientation. + * Retrieve orientation and rotation via Display. + * + * @param aDisplay The Display that has screen orientation information + * + * @return Whether the screen orientation has changed. + */ + public boolean update(final Display aDisplay) { + return update(getScreenOrientation(aDisplay)); + } + + /* + * 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(final int aAndroidOrientation) { + return update(getScreenOrientation(aAndroidOrientation, getRotation())); + } + + /* + * 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 synchronized boolean update(final ScreenOrientation aScreenOrientation) { + // Gecko expects a definite screen orientation, so we default to the + // primary orientations. + final ScreenOrientation screenOrientation; + if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_PRIMARY.value) != 0) { + screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + } else if ((aScreenOrientation.value & ScreenOrientation.PORTRAIT_SECONDARY.value) != 0) { + screenOrientation = ScreenOrientation.PORTRAIT_SECONDARY; + } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_PRIMARY.value) != 0) { + screenOrientation = ScreenOrientation.LANDSCAPE_PRIMARY; + } else if ((aScreenOrientation.value & ScreenOrientation.LANDSCAPE_SECONDARY.value) != 0) { + screenOrientation = ScreenOrientation.LANDSCAPE_SECONDARY; + } else { + screenOrientation = ScreenOrientation.PORTRAIT_PRIMARY; + } + if (mScreenOrientation == screenOrientation) { + return false; + } + mScreenOrientation = screenOrientation; + Log.d(LOGTAG, "updating to new orientation " + mScreenOrientation); + notifyListeners(mScreenOrientation); + ScreenManagerHelper.refreshScreenInfo(); + return true; + } + + private void notifyListeners(final ScreenOrientation newOrientation) { + final Runnable notifier = + new Runnable() { + @Override + public void run() { + for (final OrientationChangeListener listener : mListeners) { + listener.onScreenOrientationChanged(newOrientation); + } + } + }; + + if (ThreadUtils.isOnUiThread()) { + notifier.run(); + } else { + ThreadUtils.runOnUiThread(notifier); + } + } + + /* + * @return The Gecko screen orientation derived from Android orientation and + * rotation. + */ + public ScreenOrientation getScreenOrientation() { + return mScreenOrientation; + } + + /* + * 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( + final int aAndroidOrientation, final int aRotation) { + final boolean isPrimary = aRotation == Surface.ROTATION_0 || aRotation == Surface.ROTATION_90; + if (aAndroidOrientation == 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 == 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; + } + + /* + * Get the Gecko orientation from Display. + * + * @param aDisplay The display that has orientation information. + * + * @return Gecko screen orientation. + */ + private ScreenOrientation getScreenOrientation(final Display aDisplay) { + final Rect rect = GeckoAppShell.getScreenSizeIgnoreOverride(); + final int orientation = + rect.width() >= rect.height() ? ORIENTATION_LANDSCAPE : ORIENTATION_PORTRAIT; + return getScreenOrientation(orientation, aDisplay.getRotation()); + } + + /* + * @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. + */ + private int getRotation() { + return GeckoAppShell.getRotation(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java new file mode 100644 index 0000000000..375d6c91c2 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoSystemStateListener.java @@ -0,0 +1,161 @@ +/* -*- 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.ContentResolver; +import android.content.Context; +import android.content.res.Configuration; +import android.database.ContentObserver; +import android.hardware.input.InputManager; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.Log; +import android.view.InputDevice; +import androidx.annotation.RequiresApi; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.InputDeviceUtils; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoSystemStateListener implements InputManager.InputDeviceListener { + private static final String LOGTAG = "SystemStateListener"; + + private static final GeckoSystemStateListener listenerInstance = new GeckoSystemStateListener(); + + private boolean mInitialized; + private ContentObserver mContentObserver; + private static Context sApplicationContext; + private InputManager mInputManager; + private boolean mIsNightMode; + + public static GeckoSystemStateListener getInstance() { + return listenerInstance; + } + + private GeckoSystemStateListener() {} + + public synchronized void initialize(final Context context) { + if (mInitialized) { + Log.w(LOGTAG, "Already initialized!"); + return; + } + mInputManager = (InputManager) context.getSystemService(Context.INPUT_SERVICE); + mInputManager.registerInputDeviceListener(listenerInstance, ThreadUtils.getUiHandler()); + + sApplicationContext = context; + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + final Uri animationSetting = Settings.System.getUriFor(Settings.Global.ANIMATOR_DURATION_SCALE); + mContentObserver = + new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(final boolean selfChange) { + onDeviceChanged(); + } + }; + contentResolver.registerContentObserver(animationSetting, false, mContentObserver); + + mIsNightMode = + (sApplicationContext.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) + == Configuration.UI_MODE_NIGHT_YES; + + mInitialized = true; + } + + public synchronized void shutdown() { + if (!mInitialized) { + Log.w(LOGTAG, "Already shut down!"); + return; + } + + if (mInputManager != null) { + Log.e(LOGTAG, "mInputManager should be valid!"); + return; + } + + mInputManager.unregisterInputDeviceListener(listenerInstance); + + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + contentResolver.unregisterContentObserver(mContentObserver); + + mInitialized = false; + mInputManager = null; + mContentObserver = null; + } + + @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR1) + @WrapForJNI(calledFrom = "gecko") + /** + * For prefers-reduced-motion media queries feature. + * + * <p>Uses `Settings.Global` which was introduced in API version 17. + */ + private static boolean prefersReducedMotion() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return false; + } + + final ContentResolver contentResolver = sApplicationContext.getContentResolver(); + + return Settings.Global.getFloat(contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1) + == 0.0f; + } + + /** For prefers-color-scheme media queries feature. */ + public boolean isNightMode() { + return mIsNightMode; + } + + public void updateNightMode(final int newUIMode) { + final boolean isNightMode = + (newUIMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + if (isNightMode == mIsNightMode) { + return; + } + mIsNightMode = isNightMode; + onDeviceChanged(); + } + + @WrapForJNI(stubName = "OnDeviceChanged", calledFrom = "any", dispatchTo = "gecko") + private static native void nativeOnDeviceChanged(); + + public static void onDeviceChanged() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeOnDeviceChanged(); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, GeckoSystemStateListener.class, "nativeOnDeviceChanged"); + } + } + + private void notifyDeviceChanged(final int deviceId) { + final InputDevice device = InputDevice.getDevice(deviceId); + if (device == null || !InputDeviceUtils.isPointerTypeDevice(device)) { + return; + } + onDeviceChanged(); + } + + @Override + public void onInputDeviceAdded(final int deviceId) { + notifyDeviceChanged(deviceId); + } + + @Override + public void onInputDeviceRemoved(final int deviceId) { + // Call onDeviceChanged directly without checking device source types + // since we can no longer get a valid `InputDevice` in the case of + // device removal. + onDeviceChanged(); + } + + @Override + public void onInputDeviceChanged(final int deviceId) { + notifyDeviceChanged(deviceId); + } +} 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 0000000000..8860c1cd42 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/GeckoThread.java @@ -0,0 +1,985 @@ +/* -*- 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.res.Configuration; +import android.content.res.Resources; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.os.MessageQueue; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.StringTokenizer; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.gecko.process.GeckoProcessManager; +import org.mozilla.gecko.process.GeckoProcessType; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.geckoview.GeckoResult; + +public class GeckoThread extends Thread { + private static final String LOGTAG = "GeckoThread"; + + public enum State implements NativeQueue.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 granting request to shutdown + @WrapForJNI + EXITING(3), + // After granting request to restart + @WrapForJNI + RESTARTING(3), + // After failed lib extraction due to corrupted APK + CORRUPT_APK(2), + // 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 mRank; + + private State(final int rank) { + mRank = rank; + } + + @Override + public boolean is(final NativeQueue.State other) { + return this == other; + } + + @Override + public boolean isAtLeast(final NativeQueue.State other) { + if (other instanceof State) { + return mRank >= ((State) other).mRank; + } + return false; + } + + @Override + public String toString() { + return name(); + } + } + + // -1 denotes an invalid or missing File Descriptor + private static final int INVALID_FD = -1; + + private static final NativeQueue sNativeQueue = new NativeQueue(State.INITIAL, State.RUNNING); + + /* package */ static NativeQueue getNativeQueue() { + return sNativeQueue; + } + + public static final State MIN_STATE = State.INITIAL; + public static final State MAX_STATE = State.EXITED; + + private static final Runnable UI_THREAD_CALLBACK = + new Runnable() { + @Override + public void run() { + ThreadUtils.assertOnUiThread(); + final long nextDelay = runUiThreadCallback(); + if (nextDelay >= 0) { + ThreadUtils.getUiHandler().postDelayed(this, nextDelay); + } + } + }; + + private static final GeckoThread INSTANCE = new GeckoThread(); + + @WrapForJNI private static final ClassLoader clsLoader = GeckoThread.class.getClassLoader(); + @WrapForJNI private static MessageQueue msgQueue; + @WrapForJNI private static int uiThreadId; + + private static TelemetryUtils.Timer sInitTimer; + private static LinkedList<StateGeckoResult> sStateListeners = new LinkedList<>(); + + // Main process parameters + public static final int FLAG_DEBUGGING = 1 << 0; // Debugging mode. + public static final int FLAG_PRELOAD_CHILD = 1 << 1; // Preload child during main thread start. + public static final int FLAG_ENABLE_NATIVE_CRASHREPORTER = + 1 << 2; // Enable native crash reporting. + + /* package */ static final String EXTRA_ARGS = "args"; + + private boolean mInitialized; + private InitInfo mInitInfo; + + public static final class ParcelFileDescriptors { + public final @Nullable ParcelFileDescriptor prefs; + public final @Nullable ParcelFileDescriptor prefMap; + public final @NonNull ParcelFileDescriptor ipc; + public final @Nullable ParcelFileDescriptor crashReporter; + public final @Nullable ParcelFileDescriptor crashAnnotation; + + private ParcelFileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + crashAnnotation = builder.crashAnnotation; + } + + public FileDescriptors detach() { + return FileDescriptors.builder() + .prefs(detach(prefs)) + .prefMap(detach(prefMap)) + .ipc(detach(ipc)) + .crashReporter(detach(crashReporter)) + .crashAnnotation(detach(crashAnnotation)) + .build(); + } + + private static int detach(final ParcelFileDescriptor pfd) { + if (pfd == null) { + return INVALID_FD; + } + return pfd.detachFd(); + } + + public void close() { + close(prefs, prefMap, ipc, crashReporter, crashAnnotation); + } + + private static void close(final ParcelFileDescriptor... pfds) { + for (final ParcelFileDescriptor pfd : pfds) { + if (pfd != null) { + try { + pfd.close(); + } catch (final IOException ex) { + // Nothing we can do about this really. + Log.w(LOGTAG, "Failed to close File Descriptors.", ex); + } + } + } + } + + public static ParcelFileDescriptors from(final FileDescriptors fds) { + return ParcelFileDescriptors.builder() + .prefs(from(fds.prefs)) + .prefMap(from(fds.prefMap)) + .ipc(from(fds.ipc)) + .crashReporter(from(fds.crashReporter)) + .crashAnnotation(from(fds.crashAnnotation)) + .build(); + } + + private static ParcelFileDescriptor from(final int fd) { + if (fd == INVALID_FD) { + return null; + } + try { + return ParcelFileDescriptor.fromFd(fd); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + ParcelFileDescriptor prefs; + ParcelFileDescriptor prefMap; + ParcelFileDescriptor ipc; + ParcelFileDescriptor crashReporter; + ParcelFileDescriptor crashAnnotation; + + private Builder() {} + + public ParcelFileDescriptors build() { + return new ParcelFileDescriptors(this); + } + + public Builder prefs(final ParcelFileDescriptor prefs) { + this.prefs = prefs; + return this; + } + + public Builder prefMap(final ParcelFileDescriptor prefMap) { + this.prefMap = prefMap; + return this; + } + + public Builder ipc(final ParcelFileDescriptor ipc) { + this.ipc = ipc; + return this; + } + + public Builder crashReporter(final ParcelFileDescriptor crashReporter) { + this.crashReporter = crashReporter; + return this; + } + + public Builder crashAnnotation(final ParcelFileDescriptor crashAnnotation) { + this.crashAnnotation = crashAnnotation; + return this; + } + } + } + + public static final class FileDescriptors { + final int prefs; + final int prefMap; + final int ipc; + final int crashReporter; + final int crashAnnotation; + + private FileDescriptors(final Builder builder) { + prefs = builder.prefs; + prefMap = builder.prefMap; + ipc = builder.ipc; + crashReporter = builder.crashReporter; + crashAnnotation = builder.crashAnnotation; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + int prefs = INVALID_FD; + int prefMap = INVALID_FD; + int ipc = INVALID_FD; + int crashReporter = INVALID_FD; + int crashAnnotation = INVALID_FD; + + private Builder() {} + + public FileDescriptors build() { + return new FileDescriptors(this); + } + + public Builder prefs(final int prefs) { + this.prefs = prefs; + return this; + } + + public Builder prefMap(final int prefMap) { + this.prefMap = prefMap; + return this; + } + + public Builder ipc(final int ipc) { + this.ipc = ipc; + return this; + } + + public Builder crashReporter(final int crashReporter) { + this.crashReporter = crashReporter; + return this; + } + + public Builder crashAnnotation(final int crashAnnotation) { + this.crashAnnotation = crashAnnotation; + return this; + } + } + } + + public static class InitInfo { + public final String[] args; + public final Bundle extras; + public final int flags; + public final Map<String, Object> prefs; + public final String userSerialNumber; + + public final boolean xpcshell; + public final String outFilePath; + + public final FileDescriptors fds; + + private InitInfo(final Builder builder) { + final List<String> result = new ArrayList<>(builder.mArgs.length); + + boolean xpcshell = false; + for (final String argument : builder.mArgs) { + if ("-xpcshell".equals(argument)) { + xpcshell = true; + } else { + result.add(argument); + } + } + this.xpcshell = xpcshell; + + args = result.toArray(new String[0]); + + extras = builder.mExtras != null ? new Bundle(builder.mExtras) : new Bundle(3); + flags = builder.mFlags; + prefs = builder.mPrefs; + userSerialNumber = builder.mUserSerialNumber; + + outFilePath = xpcshell ? builder.mOutFilePath : null; + + if (builder.mFds != null) { + fds = builder.mFds; + } else { + fds = FileDescriptors.builder().build(); + } + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String[] mArgs; + private Bundle mExtras; + private int mFlags; + private Map<String, Object> mPrefs; + private String mUserSerialNumber; + + private String mOutFilePath; + + private FileDescriptors mFds; + + // Prevent direct instantiation + private Builder() {} + + public InitInfo build() { + return new InitInfo(this); + } + + public Builder args(final String[] args) { + mArgs = args; + return this; + } + + public Builder extras(final Bundle extras) { + mExtras = extras; + return this; + } + + public Builder flags(final int flags) { + mFlags = flags; + return this; + } + + public Builder prefs(final Map<String, Object> prefs) { + mPrefs = prefs; + return this; + } + + public Builder userSerialNumber(final String userSerialNumber) { + mUserSerialNumber = userSerialNumber; + return this; + } + + public Builder outFilePath(final String outFilePath) { + mOutFilePath = outFilePath; + return this; + } + + public Builder fds(final FileDescriptors fds) { + mFds = fds; + return this; + } + } + } + + private static class StateGeckoResult extends GeckoResult<Void> { + final State state; + + public StateGeckoResult(final State state) { + this.state = state; + } + } + + GeckoThread() { + // Request more (virtual) stack space to avoid overflows in the CSS frame + // constructor. 8 MB matches desktop. + super(null, null, "Gecko", 8 * 1024 * 1024); + } + + @WrapForJNI + private static boolean isChildProcess() { + final InitInfo info = INSTANCE.mInitInfo; + return info != null && info.fds.ipc != INVALID_FD; + } + + public static boolean init(final InitInfo info) { + return INSTANCE.initInternal(info); + } + + private synchronized boolean initInternal(final InitInfo info) { + ThreadUtils.assertOnUiThread(); + uiThreadId = Process.myTid(); + + if (mInitialized) { + return false; + } + + sInitTimer = new TelemetryUtils.UptimeTimer("GV_STARTUP_RUNTIME_MS"); + + mInitInfo = info; + mInitialized = true; + notifyAll(); + return true; + } + + public static boolean launch() { + ThreadUtils.assertOnUiThread(); + + if (checkAndSetState(State.INITIAL, State.LAUNCHED)) { + INSTANCE.start(); + return true; + } + return false; + } + + public static boolean isLaunched() { + return !isState(State.INITIAL); + } + + @RobocopTarget + public static boolean isRunning() { + return isState(State.RUNNING); + } + + private static void loadGeckoLibs(final Context context) { + GeckoLoader.loadSQLiteLibs(context); + GeckoLoader.loadNSSLibs(context); + GeckoLoader.loadGeckoLibs(context); + setState(State.LIBS_READY); + } + + private static void initGeckoEnvironment() { + final Context context = GeckoAppShell.getApplicationContext(); + 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); + final Configuration config = res.getConfiguration(); + config.locale = mappedLocale; + res.updateConfiguration(config, null); + } + + if (!isChildProcess()) { + GeckoSystemStateListener.getInstance().initialize(context); + } + + loadGeckoLibs(context); + } + + private String[] getMainProcessArgs() { + final Context context = GeckoAppShell.getApplicationContext(); + final ArrayList<String> args = new ArrayList<>(); + + // argv[0] is the program name, which for us is the package name. + args.add(context.getPackageName()); + + if (!mInitInfo.xpcshell) { + args.add("-greomni"); + args.add(context.getPackageResourcePath()); + } + + if (mInitInfo.args != null) { + args.addAll(Arrays.asList(mInitInfo.args)); + } + + // Legacy "args" parameter + final String extraArgs = mInitInfo.extras.getString(EXTRA_ARGS, null); + if (extraArgs != null) { + final StringTokenizer st = new StringTokenizer(extraArgs); + while (st.hasMoreTokens()) { + args.add(st.nextToken()); + } + } + + // "argX" parameters + for (int i = 0; mInitInfo.extras.containsKey("arg" + i); i++) { + final String arg = mInitInfo.extras.getString("arg" + i); + args.add(arg); + } + + return args.toArray(new String[0]); + } + + public static @Nullable Bundle getActiveExtras() { + synchronized (INSTANCE) { + if (!INSTANCE.mInitialized) { + return null; + } + return new Bundle(INSTANCE.mInitInfo.extras); + } + } + + public static int getActiveFlags() { + synchronized (INSTANCE) { + if (!INSTANCE.mInitialized) { + return 0; + } + + return INSTANCE.mInitInfo.flags; + } + } + + private static ArrayList<String> getEnvFromExtras(final Bundle extras) { + if (extras == null) { + return new ArrayList<>(); + } + + final ArrayList<String> result = new ArrayList<>(); + if (extras != null) { + String env = extras.getString("env0"); + for (int c = 1; env != null; c++) { + if (BuildConfig.DEBUG_BUILD) { + Log.d(LOGTAG, "env var: " + env); + } + result.add(env); + env = extras.getString("env" + c); + } + } + + return result; + } + + @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; + final 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); + + // Wait until initialization before preparing environment. + synchronized (this) { + while (!mInitialized) { + try { + wait(); + } catch (final InterruptedException e) { + } + } + } + + final Context context = GeckoAppShell.getApplicationContext(); + final List<String> env = getEnvFromExtras(mInitInfo.extras); + + // In Gecko, the native crash reporter is enabled by default in opt builds, and + // disabled by default in debug builds. + if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) == 0 && !BuildConfig.DEBUG_BUILD) { + env.add(0, "MOZ_CRASHREPORTER_DISABLE=1"); + } else if ((mInitInfo.flags & FLAG_ENABLE_NATIVE_CRASHREPORTER) != 0 + && BuildConfig.DEBUG_BUILD) { + env.add(0, "MOZ_CRASHREPORTER=1"); + } + + if (mInitInfo.userSerialNumber != null) { + env.add(0, "MOZ_ANDROID_USER_SERIAL_NUMBER=" + mInitInfo.userSerialNumber); + } + + // Start the profiler before even loading mozglue, so we can capture more + // things that are happening on the JVM side. + maybeStartGeckoProfiler(env); + + GeckoLoader.loadMozGlue(context); + setState(State.MOZGLUE_READY); + + final boolean isChildProcess = isChildProcess(); + + GeckoLoader.setupGeckoEnvironment( + context, + isChildProcess, + context.getFilesDir().getPath(), + env, + mInitInfo.prefs, + mInitInfo.xpcshell); + + initGeckoEnvironment(); + + if ((mInitInfo.flags & FLAG_PRELOAD_CHILD) != 0) { + // Preload the content ("tab") child process. + GeckoProcessManager.getInstance().preload(GeckoProcessType.CONTENT); + } + + if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) { + try { + Thread.sleep(5 * 1000 /* 5 seconds */); + } catch (final InterruptedException e) { + } + } + + Log.w(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - runGecko"); + + final String[] args = isChildProcess ? mInitInfo.args : getMainProcessArgs(); + + if ((mInitInfo.flags & FLAG_DEBUGGING) != 0) { + Log.i(LOGTAG, "RunGecko - args = " + TextUtils.join(" ", args)); + } + + // And go. + GeckoLoader.nativeRun( + args, + mInitInfo.fds.prefs, + mInitInfo.fds.prefMap, + mInitInfo.fds.ipc, + mInitInfo.fds.crashReporter, + mInitInfo.fds.crashAnnotation, + isChildProcess ? false : mInitInfo.xpcshell, + isChildProcess ? null : mInitInfo.outFilePath); + + // And... we're done. + final boolean restarting = isState(State.RESTARTING); + setState(State.EXITED); + + final GeckoBundle data = new GeckoBundle(1); + data.putBoolean("restart", restarting); + EventDispatcher.getInstance().dispatch("Gecko:Exited", data); + + // Remove pumpMessageLoop() idle handler + Looper.myQueue().removeIdleHandler(idleHandler); + + if (isChildProcess) { + // The child process is completely controlled by Gecko so we don't really need to keep + // it alive after Gecko exits. + System.exit(0); + } + } + + // This may start the gecko profiler early by looking at the environment variables. + // Refer to the platform side for more information about the environment variables: + // https://searchfox.org/mozilla-central/rev/2f9eacd9d3d995c937b4251a5557d95d494c9be1/tools/profiler/core/platform.cpp#2969-3072 + private static void maybeStartGeckoProfiler(final @NonNull List<String> env) { + final String startupEnv = "MOZ_PROFILER_STARTUP="; + final String intervalEnv = "MOZ_PROFILER_STARTUP_INTERVAL="; + final String capacityEnv = "MOZ_PROFILER_STARTUP_ENTRIES="; + final String filtersEnv = "MOZ_PROFILER_STARTUP_FILTERS="; + boolean isStartupProfiling = false; + // Putting default values for now, but they can be overwritten. + // Keep these values in sync with profiler defaults. + int interval = 1; + // 8M entries. Keep this in sync with `PROFILER_DEFAULT_STARTUP_ENTRIES`. + int capacity = 8 * 1024 * 1024; + // We have a default 8M of entries but user can actually put less entries + // with environment variables. But even though user can put anything, we + // have a hard cap on the minimum value count, because if it's lower than + // this value, profiler could not capture anything meaningful. + // This value is kept in `scMinimumBufferEntries` variable in the cpp side: + // https://searchfox.org/mozilla-central/rev/fa7f47027917a186fb2052dee104cd06c21dd76f/tools/profiler/core/platform.cpp#749 + // This number is not clear in the cpp code at first, so lets calculate: + // scMinimumBufferEntries = scMinimumBufferSize / scBytesPerEntry + // expands into + // scMinimumNumberOfChunks * 2 * scExpectedMaximumStackSize / scBytesPerEntry + // and this is: 4 * 2 * 64 * 1024 / 8 = 65536 (~512 kb) + final int minCapacity = 65536; + + // Set the default value of no filters - an empty array - which is safer than using null. + // If we find a user provided value, this will be overwritten. + String[] filters = new String[0]; + + // Looping the environment variable list to check known variable names. + for (final String envItem : env) { + if (envItem == null) { + continue; + } + + if (envItem.startsWith(startupEnv)) { + // Check the environment variable value to see if it's positive. + final String value = envItem.substring(startupEnv.length()); + if (value.isEmpty() || value.equals("0") || value.equals("n") || value.equals("N")) { + // ''/'0'/'n'/'N' values mean do not start the startup profiler. + // There's no need to inspect other environment variables, + // so let's break out of the loop + break; + } + + isStartupProfiling = true; + } else if (envItem.startsWith(intervalEnv)) { + // Parse the interval environment variable if present + final String value = envItem.substring(intervalEnv.length()); + + try { + final int intValue = Integer.parseInt(value); + interval = Math.max(intValue, interval); + } catch (final NumberFormatException err) { + // Failed to parse. Do nothing and just use the default value. + } + } else if (envItem.startsWith(capacityEnv)) { + // Parse the capacity environment variable if present + final String value = envItem.substring(capacityEnv.length()); + + try { + final int intValue = Integer.parseInt(value); + // See `scMinimumBufferEntries` variable for this value on the platform side. + capacity = Math.max(intValue, minCapacity); + } catch (final NumberFormatException err) { + // Failed to parse. Do nothing and just use the default value. + } + } else if (envItem.startsWith(filtersEnv)) { + filters = envItem.substring(filtersEnv.length()).split(","); + } + } + + if (isStartupProfiling) { + GeckoJavaSampler.start(filters, interval, capacity); + } + } + + @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 sNativeQueue.getState().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 sNativeQueue.getState().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 state.isAtLeast(sNativeQueue.getState()); + } + + /** + * 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 isStateAtLeast(minState) && isStateAtMost(maxState); + } + + @WrapForJNI(calledFrom = "gecko") + private static void setState(final State newState) { + checkAndSetState(null, newState); + } + + @WrapForJNI(calledFrom = "gecko") + private static boolean checkAndSetState(final State expectedState, final State newState) { + final boolean result = sNativeQueue.checkAndSetState(expectedState, newState); + if (result) { + Log.d(LOGTAG, "State changed to " + newState); + + if (sInitTimer != null && isRunning()) { + sInitTimer.stop(); + sInitTimer = null; + } + + notifyStateListeners(); + } + return result; + } + + @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); + } + + @UiThread + public static GeckoResult<Void> waitForState(final State state) { + final StateGeckoResult result = new StateGeckoResult(state); + if (isStateAtLeast(state)) { + result.complete(null); + return result; + } + + synchronized (sStateListeners) { + sStateListeners.add(result); + } + return result; + } + + private static void notifyStateListeners() { + synchronized (sStateListeners) { + final LinkedList<StateGeckoResult> newListeners = new LinkedList<>(); + for (final StateGeckoResult result : sStateListeners) { + if (!isStateAtLeast(result.state)) { + newListeners.add(result); + continue; + } + + result.complete(null); + } + + sStateListeners = newListeners; + } + } + + @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); + } + } + + @WrapForJNI(calledFrom = "ui") + /* package */ static native long runUiThreadCallback(); + + @WrapForJNI(dispatchTo = "gecko") + public static native void forceQuit(); + + @WrapForJNI(dispatchTo = "gecko") + public static native void crash(); + + @WrapForJNI + private static void requestUiThreadCallback(final long delay) { + ThreadUtils.getUiHandler().postDelayed(UI_THREAD_CALLBACK, delay); + } + + /** 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) { + sNativeQueue.queueUntilReady(cls, methodName, args); + } + + /** 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) { + sNativeQueue.queueUntilReady(obj, methodName, args); + } + + /** Queue a call to the given instance method until Gecko is in the RUNNING state. */ + public static void queueNativeCallUntil( + final State state, final Object obj, final String methodName, final Object... args) { + sNativeQueue.queueUntil(state, obj, methodName, args); + } + + /** Queue a call to the given static method until Gecko is in the RUNNING state. */ + public static void queueNativeCallUntil( + final State state, final Class<?> cls, final String methodName, final Object... args) { + sNativeQueue.queueUntil(state, cls, methodName, args); + } +} 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 0000000000..5689944717 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/InputMethods.java @@ -0,0 +1,106 @@ +/* -*- 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.content.Context; +import android.os.Build; +import android.provider.Settings.Secure; +import android.view.View; +import android.view.inputmethod.InputMethodInfo; +import android.view.inputmethod.InputMethodManager; +import java.util.Collection; + +public final class InputMethods { + public static final String METHOD_ANDROID_LATINIME = "com.android.inputmethod.latin/.LatinIME"; + // ATOK has a lot of package names since they release custom versions. + public static final String METHOD_ATOK_PREFIX = "com.justsystems.atokmobile"; + public static final String METHOD_ATOK_OEM_PREFIX = "com.atok.mobile."; + public static final String METHOD_GOOGLE_JAPANESE_INPUT = + "com.google.android.inputmethod.japanese/.MozcService"; + public static final String METHOD_ATOK_OEM_SOFTBANK = + "com.mobiroo.n.justsystems.atok/.AtokInputMethodService"; + 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_SONY = + "com.sonyericsson.textinput.uxp/.glue.InputMethodServiceGlue"; + 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(final Context context) { + final String inputMethod = + Secure.getString(context.getContentResolver(), Secure.DEFAULT_INPUT_METHOD); + return (inputMethod != null ? inputMethod : ""); + } + + public static InputMethodInfo getInputMethodInfo( + final Context context, final String inputMethod) { + final InputMethodManager imm = getInputMethodManager(context); + final Collection<InputMethodInfo> infos = imm.getEnabledInputMethodList(); + for (final InputMethodInfo info : infos) { + if (info.getId().equals(inputMethod)) { + return info; + } + } + return null; + } + + public static InputMethodManager getInputMethodManager(final Context context) { + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } + + public static void restartInput(final Context context, final View view) { + final InputMethodManager imm = getInputMethodManager(context); + if (imm != null) { + imm.restartInput(view); + } + } + + public static boolean needsSoftResetWorkaround(final String inputMethod) { + // Stock latin IME on Android 4.2 and above + return Build.VERSION.SDK_INT >= 17 + && (METHOD_ANDROID_LATINIME.equals(inputMethod) + || METHOD_GOOGLE_LATINIME.equals(inputMethod)); + } + + /** + * Check input method if we require a workaround to remove composition in {@link + * android.view.inputmethod.InputMethodManager.updateSelection}. + * + * @param inputMethod The input method name by {@link #getCurrentInputMethod}. + * @return true if {@link android.view.inputmethod.InputMethodManager.updateSelection} doesn't + * remove the composition, use {@link + * android.view.inputmethod.InputMehtodManager.restartInput} to remove it in this case. + */ + public static boolean needsRestartInput(final String inputMethod) { + return inputMethod.startsWith(METHOD_ATOK_PREFIX) + || inputMethod.startsWith(METHOD_ATOK_OEM_PREFIX) + || METHOD_ATOK_OEM_SOFTBANK.equals(inputMethod); + } + + public static boolean shouldCommitCharAsKey(final String inputMethod) { + return METHOD_HTC_TOUCH_INPUT.equals(inputMethod); + } + + public static boolean needsRestartOnReplaceRemove(final Context context) { + final String inputMethod = getCurrentInputMethod(context); + return METHOD_SONY.equals(inputMethod); + } + + // TODO: Replace usages by definition in EditorInfoCompat once available (bug 1385726). + public static final int IME_FLAG_NO_PERSONALIZED_LEARNING = 0x1000000; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java new file mode 100644 index 0000000000..2003abcc6f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MagnifiableSurfaceView.java @@ -0,0 +1,137 @@ +/* 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.graphics.Canvas; +import android.graphics.Rect; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; + +/** + * A {@link android.view.SurfaceView} which allows a {@link android.widget.Magnifier} widget to + * magnify a custom {@link android.view.Surface} rather than the SurfaceView's default Surface. + */ +public class MagnifiableSurfaceView extends SurfaceView { + private static final String LOGTAG = "MagnifiableSurfaceView"; + + private SurfaceHolderWrapper mHolder; + + public MagnifiableSurfaceView(final Context context) { + super(context); + } + + @Override + public SurfaceHolder getHolder() { + if (mHolder != null) { + // Only return our custom holder if we are being called from the Magnifier class. + // Throwable.getStackTrace() is faster than Thread.getStackTrace(), but still has a cost, + // hence why we only check the caller if we have set an override Surface. + final StackTraceElement[] stackTrace = new Throwable().getStackTrace(); + if (stackTrace.length >= 2 + && stackTrace[1].getClassName().equals("android.widget.Magnifier")) { + return mHolder; + } + } + return super.getHolder(); + } + + /** + * Sets the Surface that should be magnified by a Magnifier widget. + * + * <p>This should be set immediately before calling {@link android.widget.Magnifier#show()} or + * {@link android.widget.Magnifier#update()}, and unset immediately afterwards. + * + * @param surface The Surface to be magnified. If null, the SurfaceView's default Surface will be + * used. + */ + public void setMagnifierSurface(final Surface surface) { + if (surface != null) { + mHolder = new SurfaceHolderWrapper(getHolder(), surface); + } else { + mHolder = null; + } + } + + /** + * A {@link android.view.SurfaceHolder} implementation that simply forwards all methods to a + * provided SurfaceHolder instance, except for getSurface() which returns a custom Surface. + */ + private class SurfaceHolderWrapper implements SurfaceHolder { + private final SurfaceHolder mHolder; + private final Surface mSurface; + + public SurfaceHolderWrapper(final SurfaceHolder holder, final Surface surface) { + mHolder = holder; + mSurface = surface; + } + + @Override + public void addCallback(final Callback callback) { + mHolder.addCallback(callback); + } + + @Override + public void removeCallback(final Callback callback) { + mHolder.removeCallback(callback); + } + + @Override + public boolean isCreating() { + return mHolder.isCreating(); + } + + @Override + public void setType(final int type) { + mHolder.setType(type); + } + + @Override + public void setFixedSize(final int width, final int height) { + mHolder.setFixedSize(width, height); + } + + @Override + public void setSizeFromLayout() { + mHolder.setSizeFromLayout(); + } + + @Override + public void setFormat(final int format) { + mHolder.setFormat(format); + } + + @Override + public void setKeepScreenOn(final boolean screenOn) { + mHolder.setKeepScreenOn(screenOn); + } + + @Override + public Canvas lockCanvas() { + return mHolder.lockCanvas(); + } + + @Override + public Canvas lockCanvas(final Rect dirty) { + return mHolder.lockCanvas(dirty); + } + + @Override + public void unlockCanvasAndPost(final Canvas canvas) { + mHolder.unlockCanvasAndPost(canvas); + } + + @Override + public Rect getSurfaceFrame() { + return mHolder.getSurfaceFrame(); + } + + @Override + public Surface getSurface() { + return mSurface; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java new file mode 100644 index 0000000000..ff26d99dea --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/MultiMap.java @@ -0,0 +1,186 @@ +/* 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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Defines a map that holds a collection of values against each key. + * + * @param <K> Key type + * @param <T> Value type + */ +public class MultiMap<K, T> { + private HashMap<K, List<T>> mMap; + private final List<T> mEmptyList = Collections.unmodifiableList(new ArrayList<>()); + + /** + * Creates a MultiMap with specified initial capacity. + * + * @param count Initial capacity + */ + public MultiMap(final int count) { + mMap = new HashMap<>(count); + } + + /** Creates a MultiMap with default initial capacity. */ + public MultiMap() { + mMap = new HashMap<>(); + } + + private void ensure(final K key) { + if (!mMap.containsKey(key)) { + mMap.put(key, new ArrayList<>()); + } + } + + /** + * @return A map of key to the list of values associated to it + */ + public Map<K, List<T>> asMap() { + return mMap; + } + + /** + * @return The number of keys present in this map + */ + public int size() { + return mMap.size(); + } + + /** + * @return whether this map is empty or not + */ + public boolean isEmpty() { + return mMap.isEmpty(); + } + + /** + * Checks if a key is present in this map. + * + * @param key the key to check + * @return True if the map contains this key, false otherwise. + */ + public boolean containsKey(final @Nullable K key) { + return mMap.containsKey(key); + } + + /** + * Checks if a (key, value) pair is present in this map. + * + * @param key the key to check + * @param value the value to check + * @return true if there is a value associated to the given key, false otherwise + */ + public boolean containsEntry(final @Nullable K key, final @Nullable T value) { + if (!mMap.containsKey(key)) { + return false; + } + + return mMap.get(key).contains(value); + } + + /** + * Gets the values associated with the given key. + * + * @param key the key to check + * @return the list of values associated with keys, an empty list if no values are associated with + * key. + */ + @NonNull + public List<T> get(final @Nullable K key) { + if (!mMap.containsKey(key)) { + return mEmptyList; + } + + return mMap.get(key); + } + + /** + * Add a (key, value) mapping to this map. + * + * @param key the key to add + * @param value the value to add + */ + @Nullable + public void add(final @NonNull K key, final @NonNull T value) { + ensure(key); + mMap.get(key).add(value); + } + + /** + * Add a list of values to the given key. + * + * @param key the key to add + * @param values the list of values to add + * @return the final list of values or null if no value was added + */ + @Nullable + public List<T> addAll(final @NonNull K key, final @NonNull List<T> values) { + if (values == null || values.isEmpty()) { + return null; + } + + ensure(key); + + final List<T> result = mMap.get(key); + result.addAll(values); + return result; + } + + /** + * Remove all mappings for the given key. + * + * @param key the key + * @return values associated with the key or null if no values was present. + */ + @Nullable + public List<T> remove(final @Nullable K key) { + return mMap.remove(key); + } + + /** + * Remove a (key, value) mapping from this map + * + * @param key the key to remove + * @param value the value to remove + * @return true if the (key, value) mapping was present, false otherwise + */ + @Nullable + public boolean remove(final @Nullable K key, final @Nullable T value) { + if (!mMap.containsKey(key)) { + return false; + } + + final List<T> values = mMap.get(key); + final boolean wasPresent = values.remove(value); + + if (values.isEmpty()) { + mMap.remove(key); + } + + return wasPresent; + } + + /** Remove all mappings from this map. */ + public void clear() { + mMap.clear(); + } + + /** + * @return a set with all the keys for this map. + */ + @NonNull + public Set<K> keySet() { + return mMap.keySet(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java new file mode 100644 index 0000000000..7932e6c839 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/NativeQueue.java @@ -0,0 +1,225 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; 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.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; + +public class NativeQueue { + private static final String LOGTAG = "GeckoNativeQueue"; + + public interface State { + boolean is(final State other); + + boolean isAtLeast(final State other); + } + + private volatile State mState; + private final State mReadyState; + + public NativeQueue(final State initial, final State ready) { + mState = initial; + mReadyState = ready; + } + + public boolean isReady() { + return getState().isAtLeast(mReadyState); + } + + public State getState() { + return mState; + } + + public boolean setState(final State newState) { + return checkAndSetState(null, newState); + } + + public synchronized boolean checkAndSetState(final State expectedState, final State newState) { + if (expectedState != null && !mState.is(expectedState)) { + return false; + } + flushQueuedLocked(newState); + mState = newState; + return true; + } + + 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; + /* package */ final ArrayList<QueuedCall> mQueue = new ArrayList<>(QUEUED_CALLS_COUNT); + + // 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 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 (getState().isAtLeast(state)) { + invokeMethod(method, obj, argValues.toArray()); + return; + } + + mQueue.add(new QueuedCall(method, obj, argValues.toArray(), state)); + } + + /** + * Queue a call to the given instance method if the given current state does not satisfy the + * isReady condition. + * + * @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 synchronized void queueUntilReady( + final Object obj, final String methodName, final Object... args) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, mReadyState); + } + + /** + * Queue a call to the given static method if the given current state does not satisfy the isReady + * condition. + * + * @param cls Class that declares the static 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 synchronized void queueUntilReady( + final Class<?> cls, final String methodName, final Object... args) { + queueNativeCallLocked(cls, methodName, null, args, mReadyState); + } + + /** + * Queue a call to the given instance method if the given current state does not satisfy the given + * state. + * + * @param state The 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 synchronized void queueUntil( + final State state, final Object obj, final String methodName, final Object... args) { + queueNativeCallLocked(obj.getClass(), methodName, obj, args, state); + } + + /** + * Queue a call to the given static method if the given current state does not satisfy the given + * state. + * + * @param state The state in which the native call could be executed. + * @param cls Class that declares the static 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 synchronized void queueUntil( + final State state, final Class<?> cls, final String methodName, final Object... args) { + queueNativeCallLocked(cls, methodName, null, args, state); + } + + // Run all queued methods + private void flushQueuedLocked(final State state) { + int lastSkipped = -1; + for (int i = 0; i < mQueue.size(); i++) { + final QueuedCall call = mQueue.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. + mQueue.set(i, null); + + invokeMethod(call.method, call.target, call.args); + } + if (lastSkipped < 0) { + // We're done here; release the memory + mQueue.clear(); + } else if (lastSkipped < mQueue.size() - 1) { + // We skipped some; free up null entries at the end, + // but keep all the previous entries for later. + mQueue.subList(lastSkipped + 1, mQueue.size()).clear(); + } + } + + public synchronized void reset(final State initial) { + mQueue.clear(); + mState = initial; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java new file mode 100644 index 0000000000..edd6c7418a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/ScreenManagerHelper.java @@ -0,0 +1,24 @@ +/* -*- 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 org.mozilla.gecko.annotation.WrapForJNI; + +class ScreenManagerHelper { + + /** Trigger a refresh of the cached screen information held by Gecko. */ + public static void refreshScreenInfo() { + // Screen data is initialised automatically on startup, so no need to queue the call if + // Gecko isn't running yet. + if (GeckoThread.isRunning()) { + nativeRefreshScreenInfo(); + } + } + + @WrapForJNI(stubName = "RefreshScreenInfo", dispatchTo = "gecko") + private static native void nativeRefreshScreenInfo(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java new file mode 100644 index 0000000000..7c6f572edc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SpeechSynthesisService.java @@ -0,0 +1,230 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil -*- */ +/* vim: set ts=20 sts=4 et sw=4: */ +/* 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.os.Build; +import android.speech.tts.TextToSpeech; +import android.speech.tts.UtteranceProgressListener; +import android.util.Log; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +public class SpeechSynthesisService { + private static final String LOGTAG = "GeckoSpeechSynthesis"; + // Object type is used to make it easier to remove android.speech dependencies using Proguard. + private static Object sTTS; + + @WrapForJNI(calledFrom = "gecko") + public static void initSynth() { + initSynthInternal(); + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void initSynthInternal() { + if (sTTS != null) { + return; + } + + final Context ctx = GeckoAppShell.getApplicationContext(); + + sTTS = + new TextToSpeech( + ctx, + new TextToSpeech.OnInitListener() { + @Override + public void onInit(final int status) { + if (status != TextToSpeech.SUCCESS) { + Log.w(LOGTAG, "Failed to initialize TextToSpeech"); + return; + } + + setUtteranceListener(); + registerVoicesByLocale(); + } + }); + } + + private static TextToSpeech getTTS() { + return (TextToSpeech) sTTS; + } + + private static void registerVoicesByLocale() { + ThreadUtils.postToBackgroundThread( + new Runnable() { + @Override + public void run() { + final TextToSpeech tss = getTTS(); + if (tss == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + final Locale defaultLocale = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2 + ? tss.getDefaultLanguage() + : tss.getLanguage(); + for (final Locale locale : getAvailableLanguages()) { + final Set<String> features = tss.getFeatures(locale); + final boolean isLocal = + features != null + && features.contains(TextToSpeech.Engine.KEY_FEATURE_EMBEDDED_SYNTHESIS); + final String localeStr = locale.toString(); + registerVoice( + "moz-tts:android:" + localeStr, + locale.getDisplayName(), + localeStr.replace("_", "-"), + !isLocal, + defaultLocale == locale); + } + doneRegisteringVoices(); + } + }); + } + + private static Set<Locale> getAvailableLanguages() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // While this method was introduced in 21, it seems that it + // has not been implemented in the speech service side until 23. + final Set<Locale> availableLanguages = getTTS().getAvailableLanguages(); + if (availableLanguages != null) { + return availableLanguages; + } + } + final Set<Locale> locales = new HashSet<Locale>(); + for (final Locale locale : Locale.getAvailableLocales()) { + if (locale.getVariant().isEmpty() && getTTS().isLanguageAvailable(locale) > 0) { + locales.add(locale); + } + } + + return locales; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void registerVoice( + String uri, String name, String locale, boolean isNetwork, boolean isDefault); + + @WrapForJNI(dispatchTo = "gecko") + private static native void doneRegisteringVoices(); + + @WrapForJNI(calledFrom = "gecko") + public static String speak( + final String uri, + final String text, + final float rate, + final float pitch, + final float volume) { + final AtomicBoolean result = new AtomicBoolean(false); + final String utteranceId = UUID.randomUUID().toString(); + speakInternal(uri, text, rate, pitch, volume, utteranceId, result); + return result.get() ? utteranceId : null; + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void speakInternal( + final String uri, + final String text, + final float rate, + final float pitch, + final float volume, + final String utteranceId, + final AtomicBoolean result) { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + final HashMap<String, String> params = new HashMap<String, String>(); + params.put(TextToSpeech.Engine.KEY_PARAM_VOLUME, Float.toString(volume)); + params.put(TextToSpeech.Engine.KEY_PARAM_UTTERANCE_ID, utteranceId); + final TextToSpeech tss = (TextToSpeech) sTTS; + tss.setLanguage(new Locale(uri.substring("moz-tts:android:".length()))); + tss.setSpeechRate(rate); + tss.setPitch(pitch); + final int speakRes = tss.speak(text, TextToSpeech.QUEUE_FLUSH, params); + result.set(speakRes == TextToSpeech.SUCCESS); + } + + private static void setUtteranceListener() { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + getTTS() + .setOnUtteranceProgressListener( + new UtteranceProgressListener() { + @Override + public void onDone(final String utteranceId) { + dispatchEnd(utteranceId); + } + + @Override + public void onError(final String utteranceId) { + dispatchError(utteranceId); + } + + @Override + public void onStart(final String utteranceId) { + dispatchStart(utteranceId); + } + + @Override + public void onStop(final String utteranceId, final boolean interrupted) { + if (interrupted) { + dispatchEnd(utteranceId); + } else { + // utterance isn't started yet. + dispatchError(utteranceId); + } + } + + public void onRangeStart( + final String utteranceId, final int start, final int end, final int frame) { + dispatchBoundary(utteranceId, start, end); + } + }); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchStart(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchEnd(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchError(String utteranceId); + + @WrapForJNI(dispatchTo = "gecko") + private static native void dispatchBoundary(String utteranceId, int start, int end); + + @WrapForJNI(calledFrom = "gecko") + public static void stop() { + stopInternal(); + } + + // Extra internal method to make it easier to remove android.speech dependencies using Proguard. + private static void stopInternal() { + if (sTTS == null) { + Log.w(LOGTAG, "TextToSpeech is not initialized"); + return; + } + + getTTS().stop(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + // Android M has onStop method. If Android L or above, dispatch + // event + dispatchEnd(null); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java new file mode 100644 index 0000000000..5ba7ba5310 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/SurfaceViewWrapper.java @@ -0,0 +1,220 @@ +/* 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.graphics.Color; +import android.graphics.PixelFormat; +import android.graphics.SurfaceTexture; +import android.os.Build; +import android.util.Log; +import android.view.Surface; +import android.view.SurfaceControl; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** Provides transparent access to either a SurfaceView or TextureView */ +public class SurfaceViewWrapper { + private static final String LOGTAG = "SurfaceViewWrapper"; + + private ListenerWrapper mListenerWrapper; + private View mView; + + // Only one of these will be non-null at any point in time + SurfaceView mSurfaceView; + TextureView mTextureView; + + public SurfaceViewWrapper(final Context context) { + // By default, use SurfaceView + mListenerWrapper = new ListenerWrapper(); + initSurfaceView(context); + } + + private void initSurfaceView(final Context context) { + mSurfaceView = new MagnifiableSurfaceView(context); + mSurfaceView.setBackgroundColor(Color.TRANSPARENT); + mSurfaceView.getHolder().setFormat(PixelFormat.TRANSPARENT); + mView = mSurfaceView; + } + + public void useSurfaceView(final Context context) { + if (mTextureView != null) { + mListenerWrapper.onSurfaceTextureDestroyed(mTextureView.getSurfaceTexture()); + mTextureView = null; + } + mListenerWrapper.reset(); + initSurfaceView(context); + } + + public void useTextureView(final Context context) { + if (mSurfaceView != null) { + mListenerWrapper.surfaceDestroyed(mSurfaceView.getHolder()); + mSurfaceView = null; + } + mListenerWrapper.reset(); + mTextureView = new TextureView(context); + mTextureView.setSurfaceTextureListener(mListenerWrapper); + mView = mTextureView; + } + + public void setBackgroundColor(final int color) { + if (mSurfaceView != null) { + mSurfaceView.setBackgroundColor(color); + } else { + Log.e(LOGTAG, "TextureView doesn't support background color."); + } + } + + public void setListener(final Listener listener) { + mListenerWrapper.mListener = listener; + mSurfaceView.getHolder().addCallback(mListenerWrapper); + } + + public int getWidth() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurfaceFrame().right; + } + return mListenerWrapper.mWidth; + } + + public int getHeight() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurfaceFrame().bottom; + } + return mListenerWrapper.mHeight; + } + + /** + * Returns the SurfaceControl associated with the SurfaceView, or null on unsupported SDK versions + * or when using the TextureView backend. + */ + public SurfaceControl getSurfaceControl() { + if (mSurfaceView != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return mSurfaceView.getSurfaceControl(); + } + + return null; + } + + public Surface getSurface() { + if (mSurfaceView != null) { + return mSurfaceView.getHolder().getSurface(); + } + + return mListenerWrapper.mSurface; + } + + public View getView() { + return mView; + } + + /** + * Returns whether the Surface's underlying BufferQueue has been abandoned. + * + * <p>On some devices a Surface obtained during the surfaceChanged callback can become abandoned + * by the time we attempt to use it, despite surfaceDestroyed not being called. Attempting to + * render in to such a Surface will fail. This function checks whether a Surface is in such state, + * allowing us to request a new Surface if so. + */ + public static boolean isSurfaceAbandoned(final Surface surface) { + // If JNI hasn't been loaded yet we cannot check whether the Surface has been abandoned. + // Just assume the Surface is okay. + if (!GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) { + return false; + } + + return isSurfaceAbandonedNative(surface); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current", stubName = "IsSurfaceAbandoned") + private static native boolean isSurfaceAbandonedNative(final Surface surface); + + /** + * Translates SurfaceTextureListener and SurfaceHolder.Callback into a common interface + * SurfaceViewWrapper.Listener + */ + private class ListenerWrapper + implements TextureView.SurfaceTextureListener, SurfaceHolder.Callback { + private Listener mListener; + + // TextureView doesn't provide getters for these so we keep track of them here + private Surface mSurface; + private int mWidth; + private int mHeight; + + public void reset() { + mWidth = 0; + mHeight = 0; + mSurface = null; + } + + // TextureView + @Override + public void onSurfaceTextureAvailable( + final SurfaceTexture surface, final int width, final int height) { + mSurface = new Surface(surface); + mWidth = width; + mHeight = height; + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, width, height); + } + } + + @Override + public void onSurfaceTextureSizeChanged( + final SurfaceTexture surface, final int width, final int height) { + mWidth = width; + mHeight = height; + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight); + } + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surface) { + if (mListener != null) { + mListener.onSurfaceDestroyed(); + } + mSurface = null; + return false; + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surface) { + mSurface = new Surface(surface); + if (mListener != null) { + mListener.onSurfaceChanged(mSurface, null, mWidth, mHeight); + } + } + + // SurfaceView + @Override + public void surfaceCreated(final SurfaceHolder holder) {} + + @Override + public void surfaceChanged( + final SurfaceHolder holder, final int format, final int width, final int height) { + if (mListener != null) { + mListener.onSurfaceChanged(holder.getSurface(), getSurfaceControl(), width, height); + } + } + + @Override + public void surfaceDestroyed(final SurfaceHolder holder) { + if (mListener != null) { + mListener.onSurfaceDestroyed(); + } + } + } + + public interface Listener { + void onSurfaceChanged(Surface surface, SurfaceControl surfaceControl, int width, int height); + + void onSurfaceDestroyed(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java new file mode 100644 index 0000000000..3c9c1f90a0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/TelemetryUtils.java @@ -0,0 +1,102 @@ +/* -*- 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.SystemClock; +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * All telemetry times are relative to one of two clocks: + * + * <p>* Real time since the device was booted, including deep sleep. Use this as a substitute for + * wall clock. * Uptime since the device was booted, excluding deep sleep. Use this to avoid timing + * a user activity when their phone is in their pocket! + * + * <p>The majority of methods in this class are defined in terms of real time. + */ +public class TelemetryUtils { + private static final String LOGTAG = "TelemetryUtils"; + + @WrapForJNI(stubName = "AddHistogram", dispatchTo = "gecko") + private static native void nativeAddHistogram(String name, int value); + + public static long uptime() { + return SystemClock.uptimeMillis(); + } + + public static long realtime() { + return SystemClock.elapsedRealtime(); + } + + // Define new histograms in: + // toolkit/components/telemetry/Histograms.json + public static void addToHistogram(final String name, final int value) { + if (GeckoThread.isRunning()) { + nativeAddHistogram(name, value); + } else { + GeckoThread.queueNativeCall( + TelemetryUtils.class, "nativeAddHistogram", String.class, name, value); + } + } + + public abstract static class Timer { + private final long mStartTime; + private final String mName; + + private volatile boolean mHasFinished; + private volatile long mElapsed = -1; + + protected abstract long now(); + + public Timer(final String name) { + mName = name; + mStartTime = now(); + } + + public void cancel() { + mHasFinished = true; + } + + public long getElapsed() { + return mElapsed; + } + + public void stop() { + // Only the first stop counts. + if (mHasFinished) { + return; + } + + mHasFinished = true; + + final long elapsed = now() - mStartTime; + if (elapsed < 0) { + Log.e(LOGTAG, "Current time less than start time -- clock shenanigans?"); + return; + } + + mElapsed = elapsed; + if (elapsed > Integer.MAX_VALUE) { + Log.e(LOGTAG, "Duration of " + elapsed + "ms is too great to add to histogram."); + return; + } + + addToHistogram(mName, (int) (elapsed)); + } + } + + public static class UptimeTimer extends Timer { + public UptimeTimer(final String name) { + super(name); + } + + @Override + protected long now() { + return TelemetryUtils.uptime(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.java new file mode 100644 index 0000000000..805e0a3f79 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/BuildFlag.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.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 classes that are conditionally built behind build flags. Any + * generated JNI bindings will incorporate the specified build flags. + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface BuildFlag { + /** + * Preprocessor macro for conditionally building the generated bindings. "MOZ_FOO" wraps generated + * bindings in "#ifdef MOZ_FOO / #endif" "!MOZ_FOO" wraps generated bindings in "#ifndef MOZ_FOO / + * #endif" + */ + String value() default ""; +} 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 0000000000..d6140a1ffb --- /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 0000000000..e873ebeb96 --- /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 0000000000..e15875dc8b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/RobocopTarget.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 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 0000000000..f58dea1487 --- /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 0000000000..6a3fcfcb1c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/annotation/WrapForJNI.java @@ -0,0 +1,56 @@ +/* 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. - "abort" will cause a crash if there is + * a pending exception. - "ignore" will not handle any pending exceptions; it is then the caller's + * responsibility to handle exceptions. - "nsresult" will clear any pending exceptions and return + * an error code; 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. - "current" indicates no dispatching; + * only supported value for fields, constructors, non-native methods, and non-void native methods. + * - "gecko" indicates dispatching to the Gecko XPCOM (nsThread) event queue. - "gecko_priority" + * indicates dispatching to the Gecko widget (nsAppShell) event queue; in most cases, events in + * the widget event queue (aka native event queue) are favored over events in the XPCOM event + * queue. - "proxy" indicates dispatching to a proxy function as a function object; see + * widget/jni/Natives.h. + */ + String dispatchTo() default "current"; + + /** Generate a getter instead of a literal. Only supported for static final fields. */ + boolean noLiteral() default false; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java new file mode 100644 index 0000000000..c87bf466d0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/AndroidVsync.java @@ -0,0 +1,72 @@ +/* -*- 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.os.Handler; +import android.os.Looper; +import android.view.Choreographer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** This class receives HW vsync events through a {@link Choreographer}. */ +@WrapForJNI +/* package */ final class AndroidVsync extends JNIObject implements Choreographer.FrameCallback { + @WrapForJNI + @Override // JNIObject + protected native void disposeNative(); + + private static final String LOGTAG = "AndroidVsync"; + + /* package */ Choreographer mChoreographer; + private volatile boolean mObservingVsync; + + public AndroidVsync() { + final Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post( + new Runnable() { + @Override + public void run() { + mChoreographer = Choreographer.getInstance(); + if (mObservingVsync) { + mChoreographer.postFrameCallback(AndroidVsync.this); + } + } + }); + } + + @WrapForJNI(stubName = "NotifyVsync") + private native void nativeNotifyVsync(final long frameTimeNanos); + + // Choreographer callback implementation. + public void doFrame(final long frameTimeNanos) { + if (mObservingVsync) { + mChoreographer.postFrameCallback(this); + nativeNotifyVsync(frameTimeNanos); + } + } + + /** + * Start/stop observing Vsync event. + * + * @param enable true to start observing; false to stop. + * @return true if observing and false if not. + */ + @WrapForJNI + public synchronized boolean observeVsync(final boolean enable) { + if (mObservingVsync != enable) { + mObservingVsync = enable; + + if (mChoreographer != null) { + if (enable) { + mChoreographer.postFrameCallback(this); + } else { + mChoreographer.removeFrameCallback(this); + } + } + } + return mObservingVsync; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java new file mode 100644 index 0000000000..1378a284b7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/CompositorSurfaceManager.java @@ -0,0 +1,26 @@ +/* -*- 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.os.RemoteException; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class CompositorSurfaceManager { + private static final String LOGTAG = "CompSurfManager"; + + private ICompositorSurfaceManager mManager; + + public CompositorSurfaceManager(final ICompositorSurfaceManager aManager) { + mManager = aManager; + } + + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) + throws RemoteException { + mManager.onSurfaceChanged(widgetId, surface); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java new file mode 100644 index 0000000000..6ff05f9555 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurface.java @@ -0,0 +1,147 @@ +/* -*- 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 static org.mozilla.geckoview.BuildConfig.DEBUG_BUILD; + +import android.graphics.SurfaceTexture; +import android.os.Parcel; +import android.os.Parcelable; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class GeckoSurface extends Surface { + private static final String LOGTAG = "GeckoSurface"; + + private long mHandle; + private boolean mIsSingleBuffer; + private volatile boolean mIsAvailable; + private boolean mOwned = true; + private volatile boolean mIsReleased = false; + + private int mMyPid; + // Locally allocated surface/texture. Do not pass it over IPC. + private GeckoSurface mSyncSurface; + + @WrapForJNI(exceptionMode = "nsresult") + public GeckoSurface(final GeckoSurfaceTexture gst) { + super(gst); + mHandle = gst.getHandle(); + mIsSingleBuffer = gst.isSingleBuffer(); + mIsAvailable = true; + mMyPid = android.os.Process.myPid(); + } + + public GeckoSurface(final Parcel p, final SurfaceTexture dummy) { + // A no-arg constructor exists, but is hidden in the SDK. We need to create a dummy + // SurfaceTexture here in order to create the instance. This is used to transfer the + // GeckoSurface across binder. + super(dummy); + + readFromParcel(p); + mHandle = p.readLong(); + mIsSingleBuffer = p.readByte() == 1 ? true : false; + mIsAvailable = (p.readByte() == 1 ? true : false); + mMyPid = p.readInt(); + + dummy.release(); + } + + public static final Parcelable.Creator<GeckoSurface> CREATOR = + new Parcelable.Creator<GeckoSurface>() { + public GeckoSurface createFromParcel(final Parcel p) { + return new GeckoSurface(p, new SurfaceTexture(0)); + } + + public GeckoSurface[] newArray(final int size) { + return new GeckoSurface[size]; + } + }; + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + if ((flags & Parcelable.PARCELABLE_WRITE_RETURN_VALUE) == 0) { + // GeckoSurface can be passed across processes as a return value or + // an argument, and should always tranfers its ownership (move) to + // the receiver of parcel. On the other hand, Surface is moved only + // when passed as a return value and releases itself when corresponding + // write flags is provided. (See Surface.writeToParcel().) + // The superclass method must be called here to ensure the local instance + // is truely forgotten. + super.release(); + } + mOwned = false; + + out.writeLong(mHandle); + out.writeByte((byte) (mIsSingleBuffer ? 1 : 0)); + out.writeByte((byte) (mIsAvailable ? 1 : 0)); + out.writeInt(mMyPid); + } + + @Override + public void release() { + if (mIsReleased) { + return; + } + mIsReleased = true; + + if (mSyncSurface != null) { + mSyncSurface.release(); + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(mSyncSurface.getHandle()); + if (gst != null) { + gst.decrementUse(); + } + mSyncSurface = null; + } + + if (mOwned) { + super.release(); + } + } + + @WrapForJNI + public long getHandle() { + return mHandle; + } + + @WrapForJNI + public boolean getAvailable() { + return mIsAvailable; + } + + @WrapForJNI + public boolean isReleased() { + return mIsReleased; + } + + @WrapForJNI + public void setAvailable(final boolean available) { + mIsAvailable = available; + } + + /* package */ boolean inProcess() { + return android.os.Process.myPid() == mMyPid; + } + + /* package */ SyncConfig initSyncSurface(final int width, final int height) { + if (DEBUG_BUILD) { + if (inProcess()) { + throw new AssertionError("no need for sync when allocated in process"); + } + } + if (GeckoSurfaceTexture.lookup(mHandle) != null) { + throw new AssertionError("texture#" + mHandle + " already in use."); + } + final GeckoSurfaceTexture texture = + GeckoSurfaceTexture.acquire(GeckoSurfaceTexture.isSingleBufferSupported(), mHandle); + texture.setDefaultBufferSize(width, height); + texture.track(mHandle); + mSyncSurface = new GeckoSurface(texture); + + return new SyncConfig(mHandle, mSyncSurface, width, height); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java new file mode 100644 index 0000000000..065fb8570a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/GeckoSurfaceTexture.java @@ -0,0 +1,323 @@ +/* -*- 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.SurfaceTexture; +import android.os.Build; +import android.util.Log; +import android.util.LongSparseArray; +import androidx.annotation.RequiresApi; +import java.util.LinkedList; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/* package */ final class GeckoSurfaceTexture extends SurfaceTexture { + private static final String LOGTAG = "GeckoSurfaceTexture"; + private static final int MAX_SURFACE_TEXTURES = 200; + private static final LongSparseArray<GeckoSurfaceTexture> sSurfaceTextures = + new LongSparseArray<GeckoSurfaceTexture>(); + + private static LongSparseArray<LinkedList<GeckoSurfaceTexture>> sUnusedTextures = + new LongSparseArray<LinkedList<GeckoSurfaceTexture>>(); + + private long mHandle; + private boolean mIsSingleBuffer; + + private long mAttachedContext; + private int mTexName; + + private GeckoSurfaceTexture.Callbacks mListener; + private AtomicInteger mUseCount; + private boolean mFinalized; + + private long mUpstream; + private NativeGLBlitHelper mBlitter; + + private GeckoSurfaceTexture(final long handle) { + super(0); + init(handle, false); + } + + @RequiresApi(api = Build.VERSION_CODES.KITKAT) + private GeckoSurfaceTexture(final long handle, final boolean singleBufferMode) { + super(0, singleBufferMode); + init(handle, singleBufferMode); + } + + @Override + protected void finalize() throws Throwable { + // We only want finalize() to be called once + if (mFinalized) { + return; + } + + mFinalized = true; + super.finalize(); + } + + private void init(final long handle, final boolean singleBufferMode) { + mHandle = handle; + mIsSingleBuffer = singleBufferMode; + mUseCount = new AtomicInteger(1); + + // Start off detached + detachFromGLContext(); + } + + @WrapForJNI + public long getHandle() { + return mHandle; + } + + @WrapForJNI + public int getTexName() { + return mTexName; + } + + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void attachToGLContext(final long context, final int texName) { + if (context == mAttachedContext && texName == mTexName) { + return; + } + + attachToGLContext(texName); + + mAttachedContext = context; + mTexName = texName; + } + + @Override + @WrapForJNI(exceptionMode = "nsresult") + public synchronized void detachFromGLContext() { + super.detachFromGLContext(); + + mAttachedContext = mTexName = 0; + } + + @WrapForJNI + public synchronized boolean isAttachedToGLContext(final long context) { + return mAttachedContext == context; + } + + @WrapForJNI + public boolean isSingleBuffer() { + return mIsSingleBuffer; + } + + @Override + @WrapForJNI + public synchronized void updateTexImage() { + try { + if (mUpstream != 0) { + SurfaceAllocator.sync(mUpstream); + } + super.updateTexImage(); + if (mListener != null) { + mListener.onUpdateTexImage(); + } + } catch (final Exception e) { + Log.w(LOGTAG, "updateTexImage() failed", e); + } + } + + @Override + public synchronized void release() { + mUpstream = 0; + if (mBlitter != null) { + mBlitter.close(); + } + try { + super.release(); + synchronized (sSurfaceTextures) { + sSurfaceTextures.remove(mHandle); + } + } catch (final Exception e) { + Log.w(LOGTAG, "release() failed", e); + } + } + + @Override + @WrapForJNI + public synchronized void releaseTexImage() { + if (!mIsSingleBuffer) { + return; + } + + try { + super.releaseTexImage(); + if (mListener != null) { + mListener.onReleaseTexImage(); + } + } catch (final Exception e) { + Log.w(LOGTAG, "releaseTexImage() failed", e); + } + } + + public synchronized void setListener(final GeckoSurfaceTexture.Callbacks listener) { + mListener = listener; + } + + @WrapForJNI + public static boolean isSingleBufferSupported() { + return Build.VERSION.SDK_INT >= 19; + } + + @WrapForJNI + public synchronized void incrementUse() { + mUseCount.incrementAndGet(); + } + + @WrapForJNI + public synchronized void decrementUse() { + final int useCount = mUseCount.decrementAndGet(); + + if (useCount == 0) { + setListener(null); + + if (mAttachedContext == 0) { + release(); + synchronized (sUnusedTextures) { + sSurfaceTextures.remove(mHandle); + } + return; + } + + synchronized (sUnusedTextures) { + LinkedList<GeckoSurfaceTexture> list = sUnusedTextures.get(mAttachedContext); + if (list == null) { + list = new LinkedList<GeckoSurfaceTexture>(); + sUnusedTextures.put(mAttachedContext, list); + } + list.addFirst(this); + } + } + } + + @WrapForJNI + public static void destroyUnused(final long context) { + final LinkedList<GeckoSurfaceTexture> list; + synchronized (sUnusedTextures) { + list = sUnusedTextures.get(context); + sUnusedTextures.delete(context); + } + + if (list == null) { + return; + } + + for (final GeckoSurfaceTexture tex : list) { + try { + if (tex.isSingleBuffer()) { + tex.releaseTexImage(); + } + + tex.detachFromGLContext(); + tex.release(); + + // We need to manually call finalize here, otherwise we can run out + // of file descriptors if the GC doesn't kick in soon enough. Bug 1416015. + try { + tex.finalize(); + } catch (final Throwable t) { + Log.e(LOGTAG, "Failed to finalize SurfaceTexture", t); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to destroy SurfaceTexture", e); + } + } + } + + public static GeckoSurfaceTexture acquire(final boolean singleBufferMode, final long handle) { + if (singleBufferMode && !isSingleBufferSupported()) { + throw new IllegalArgumentException("single buffer mode not supported on API version < 19"); + } + + synchronized (sSurfaceTextures) { + // We want to limit the maximum number of SurfaceTextures at any one time. + // This is because they use a large number of fds, and once the process' limit + // is reached bad things happen. See bug 1421586. + if (sSurfaceTextures.size() >= MAX_SURFACE_TEXTURES) { + return null; + } + + if (sSurfaceTextures.indexOfKey(handle) >= 0) { + throw new IllegalArgumentException("Already have a GeckoSurfaceTexture with that handle"); + } + + final GeckoSurfaceTexture gst; + if (isSingleBufferSupported()) { + gst = new GeckoSurfaceTexture(handle, singleBufferMode); + } else { + gst = new GeckoSurfaceTexture(handle); + } + + sSurfaceTextures.put(handle, gst); + + return gst; + } + } + + @WrapForJNI + public static GeckoSurfaceTexture lookup(final long handle) { + synchronized (sSurfaceTextures) { + return sSurfaceTextures.get(handle); + } + } + + /* package */ synchronized void track(final long upstream) { + mUpstream = upstream; + } + + /* package */ synchronized void configureSnapshot( + final GeckoSurface target, final int width, final int height) { + mBlitter = NativeGLBlitHelper.create(mHandle, target, width, height); + } + + /* package */ synchronized void takeSnapshot() { + mBlitter.blit(); + } + + public interface Callbacks { + void onUpdateTexImage(); + + void onReleaseTexImage(); + } + + @WrapForJNI + public static final class NativeGLBlitHelper extends JNIObject { + public static NativeGLBlitHelper create( + final long textureHandle, + final GeckoSurface targetSurface, + final int width, + final int height) { + final NativeGLBlitHelper helper = nativeCreate(textureHandle, targetSurface, width, height); + helper.mTargetSurface = targetSurface; // Take ownership of surface. + return helper; + } + + public static native NativeGLBlitHelper nativeCreate( + final long textureHandle, + final GeckoSurface targetSurface, + final int width, + final int height); + + public native void blit(); + + public void close() { + disposeNative(); + if (mTargetSurface != null) { + mTargetSurface.release(); + mTargetSurface = null; + } + } + + @Override + protected native void disposeNative(); + + private GeckoSurface mTargetSurface; + } +} 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 0000000000..b8ceb74f0b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/PanningPerfAPI.java @@ -0,0 +1,71 @@ +/* -*- 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.os.SystemClock; +import android.util.Log; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.annotation.RobocopTarget; + +public final 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/RemoteSurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java new file mode 100644 index 0000000000..3244519da1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/RemoteSurfaceAllocator.java @@ -0,0 +1,77 @@ +/* -*- 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.gfx; + +import java.util.concurrent.atomic.AtomicInteger; + +public final class RemoteSurfaceAllocator extends ISurfaceAllocator.Stub { + private static final String LOGTAG = "RemoteSurfaceAllocator"; + + private static RemoteSurfaceAllocator mInstance; + + private final int mAllocatorId; + /// Monotonically increasing counter used to generate unique handles + /// for each SurfaceTexture by combining with mAllocatorId. + private static AtomicInteger sNextHandle = new AtomicInteger(1); + + /** + * Retrieves the singleton allocator instance for this process. + * + * @param allocatorId A unique ID identifying the process this instance belongs to, which must be + * 0 for the parent process instance. + */ + public static synchronized RemoteSurfaceAllocator getInstance(final int allocatorId) { + if (mInstance == null) { + mInstance = new RemoteSurfaceAllocator(allocatorId); + } + return mInstance; + } + + private RemoteSurfaceAllocator(final int allocatorId) { + mAllocatorId = allocatorId; + } + + @Override + public GeckoSurface acquireSurface( + final int width, final int height, final boolean singleBufferMode) { + final long handle = ((long) mAllocatorId << 32) | sNextHandle.getAndIncrement(); + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.acquire(singleBufferMode, handle); + + if (gst == null) { + return null; + } + + if (width > 0 && height > 0) { + gst.setDefaultBufferSize(width, height); + } + + return new GeckoSurface(gst); + } + + @Override + public void releaseSurface(final long handle) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle); + if (gst != null) { + gst.decrementUse(); + } + } + + @Override + public void configureSync(final SyncConfig config) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(config.sourceTextureHandle); + if (gst != null) { + gst.configureSnapshot(config.targetSurface, config.width, config.height); + } + } + + @Override + public void sync(final long handle) { + final GeckoSurfaceTexture gst = GeckoSurfaceTexture.lookup(handle); + if (gst != null) { + gst.takeSnapshot(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.java new file mode 100644 index 0000000000..f602959040 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceAllocator.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.gfx; + +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.util.LongSparseArray; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.process.GeckoProcessManager; +import org.mozilla.gecko.process.GeckoServiceChildProcess; + +/* package */ final class SurfaceAllocator { + private static final String LOGTAG = "SurfaceAllocator"; + + private static ISurfaceAllocator sAllocator; + + // Keep a reference to all allocated Surfaces, so that we can release them if we lose the + // connection to the allocator service. + private static final LongSparseArray<GeckoSurface> sSurfaces = + new LongSparseArray<GeckoSurface>(); + + private static synchronized void ensureConnection() { + if (sAllocator != null) { + return; + } + + try { + if (GeckoAppShell.isParentProcess()) { + sAllocator = GeckoProcessManager.getInstance().getSurfaceAllocator(); + } else { + sAllocator = GeckoServiceChildProcess.getSurfaceAllocator(); + } + + if (sAllocator == null) { + Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator"); + return; + } + sAllocator + .asBinder() + .linkToDeath( + new IBinder.DeathRecipient() { + @Override + public void binderDied() { + Log.w(LOGTAG, "RemoteSurfaceAllocator died"); + synchronized (SurfaceAllocator.class) { + // Our connection to the remote allocator has died, so all our surfaces are + // invalid. Release them all now. When their owners attempt to render in to + // them they can detect they have been released and allocate new ones instead. + for (int i = 0; i < sSurfaces.size(); i++) { + sSurfaces.valueAt(i).release(); + } + sSurfaces.clear(); + sAllocator = null; + } + } + }, + 0); + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to connect to RemoteSurfaceAllocator", e); + sAllocator = null; + } + } + + @WrapForJNI + public static synchronized GeckoSurface acquireSurface( + final int width, final int height, final boolean singleBufferMode) { + try { + ensureConnection(); + + if (sAllocator == null) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface: not connected"); + return null; + } + + if (singleBufferMode && !GeckoSurfaceTexture.isSingleBufferSupported()) { + return null; + } + + final GeckoSurface surface = sAllocator.acquireSurface(width, height, singleBufferMode); + if (surface == null) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface: RemoteSurfaceAllocator returned null"); + return null; + } + sSurfaces.put(surface.getHandle(), surface); + + if (!surface.inProcess()) { + sAllocator.configureSync(surface.initSyncSurface(width, height)); + } + return surface; + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to acquire GeckoSurface", e); + return null; + } + } + + @WrapForJNI + public static synchronized void disposeSurface(final GeckoSurface surface) { + // If the surface has already been released (probably due to losing connection to the remote + // allocator) then there is nothing to do here. + if (surface.isReleased()) { + return; + } + + sSurfaces.remove(surface.getHandle()); + + // Release our Surface + surface.release(); + + if (sAllocator == null) { + return; + } + + // Release the SurfaceTexture on the other side. If we have lost connection then do nothing, as + // there is nothing on the other side to release. + try { + if (sAllocator != null) { + sAllocator.releaseSurface(surface.getHandle()); + } + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to release surface texture", e); + } + } + + public static synchronized void sync(final long upstream) { + // Sync from the SurfaceTexture on the other side. If we have lost connection then do nothing, + // as there is nothing on the other side to sync from. + try { + if (sAllocator != null) { + sAllocator.sync(upstream); + } + } catch (final RemoteException e) { + Log.w(LOGTAG, "Failed to sync texture", e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java new file mode 100644 index 0000000000..29da401da0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SurfaceControlManager.java @@ -0,0 +1,93 @@ +/* -*- 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.gfx; + +import android.os.Build; +import android.view.Surface; +import android.view.SurfaceControl; +import androidx.annotation.RequiresApi; +import java.util.Iterator; +import java.util.Map; +import java.util.WeakHashMap; +import org.mozilla.gecko.annotation.WrapForJNI; + +// A helper class that creates Surfaces from SurfaceControl objects, for the widget to render in to. +// Unlike the Surfaces provided to the widget directly from the application, these are suitable for +// use in the GPU process as well as the main process. +// +// The reason we must not render directly in to the Surface provided by the application from the GPU +// process is because of a bug on Android versions 12 and later: when the GPU process dies the +// Surface is not detached from the dead process' EGL surface, and any subsequent attempts to +// attach another EGL surface to the Surface will fail. +// +// The application is therefore required to provide the SurfaceControl object to a GeckoDisplay +// whenever rendering in to a SurfaceView. The widget will then obtain a Surface from that +// SurfaceControl using getChildSurface(). Internally, this creates another SurfaceControl as a +// child of the provided SurfaceControl, then creates the Surface from that child. If the GPU +// process dies we are able to simply destroy and recreate the child SurfaceControl objects, thereby +// avoiding the bug. +public class SurfaceControlManager { + private static final String LOGTAG = "SurfaceControlManager"; + + private static final SurfaceControlManager sInstance = new SurfaceControlManager(); + + private WeakHashMap<SurfaceControl, SurfaceControl> mChildSurfaceControls = new WeakHashMap<>(); + + @WrapForJNI + public static SurfaceControlManager getInstance() { + return sInstance; + } + + // Returns a Surface of the requested size that will be composited in to the specified + // SurfaceControl. + @RequiresApi(api = Build.VERSION_CODES.Q) + @WrapForJNI(exceptionMode = "abort") + public synchronized Surface getChildSurface( + final SurfaceControl parent, final int width, final int height) { + SurfaceControl child = mChildSurfaceControls.get(parent); + if (child == null) { + // We must periodically check if any of the SurfaceControls we are managing have been + // destroyed, as we are unable to directly listen to their SurfaceViews' surfaceDestroyed + // callbacks, and they may not be attached to any compositor when they are destroyed meaning + // we cannot perform cleanup in response to the compositor being paused. + // Doing so here, when we encounter a new SurfaceControl instance, is a reasonable guess as to + // when a previous instance may have been released. + final Iterator<Map.Entry<SurfaceControl, SurfaceControl>> it = + mChildSurfaceControls.entrySet().iterator(); + while (it.hasNext()) { + final Map.Entry<SurfaceControl, SurfaceControl> entry = it.next(); + if (!entry.getKey().isValid()) { + it.remove(); + } + } + + child = new SurfaceControl.Builder().setParent(parent).setName("GeckoSurface").build(); + mChildSurfaceControls.put(parent, child); + } + + new SurfaceControl.Transaction() + .setVisibility(child, true) + .setBufferSize(child, width, height) + .apply(); + + return new Surface(child); + } + + // Must be called whenever the GPU process has died. This destroys all the child SurfaceControls + // that have been created, meaning subsequent calls to getChildSurface() will create new ones. + @RequiresApi(api = Build.VERSION_CODES.Q) + @WrapForJNI(exceptionMode = "abort") + public synchronized void onGpuProcessLoss() { + for (final SurfaceControl child : mChildSurfaceControls.values()) { + // We could reparent the child SurfaceControl to null here to immediately remove it from the + // tree. However, this will result in a black screen while we wait for the new compositor to + // be created. It's preferable for the user to see the old content instead, so simply call + // release(). + child.release(); + } + mChildSurfaceControls.clear(); + } +} 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 0000000000..0ba79d1f42 --- /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.gfx; + +import android.graphics.SurfaceTexture; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/* package */ final class SurfaceTextureListener extends JNIObject + implements SurfaceTexture.OnFrameAvailableListener { + @WrapForJNI(calledFrom = "gecko") + private SurfaceTextureListener() {} + + @WrapForJNI(dispatchTo = "gecko") + @Override // JNIObject + protected native void disposeNative(); + + @Override + protected void finalize() { + disposeNative(); + } + + @WrapForJNI(stubName = "OnFrameAvailable") + private native void nativeOnFrameAvailable(); + + @Override // SurfaceTexture.OnFrameAvailableListener + public void onFrameAvailable(final 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/SyncConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java new file mode 100644 index 0000000000..d8e2099ddc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/gfx/SyncConfig.java @@ -0,0 +1,59 @@ +/* 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.os.Parcel; +import android.os.Parcelable; + +/* package */ final class SyncConfig implements Parcelable { + final long sourceTextureHandle; + final GeckoSurface targetSurface; + final int width; + final int height; + + /* package */ SyncConfig( + final long sourceTextureHandle, + final GeckoSurface targetSurface, + final int width, + final int height) { + this.sourceTextureHandle = sourceTextureHandle; + this.targetSurface = targetSurface; + this.width = width; + this.height = height; + } + + public static final Creator<SyncConfig> CREATOR = + new Creator<SyncConfig>() { + @Override + public SyncConfig createFromParcel(final Parcel parcel) { + return new SyncConfig(parcel); + } + + @Override + public SyncConfig[] newArray(final int i) { + return new SyncConfig[i]; + } + }; + + private SyncConfig(final Parcel parcel) { + sourceTextureHandle = parcel.readLong(); + targetSurface = GeckoSurface.CREATOR.createFromParcel(parcel); + width = parcel.readInt(); + height = parcel.readInt(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel parcel, final int flags) { + parcel.writeLong(sourceTextureHandle); + targetSurface.writeToParcel(parcel, flags); + parcel.writeInt(width); + parcel.writeInt(height); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java new file mode 100644 index 0000000000..bd37a6ae74 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodec.java @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Handler; +import android.view.Surface; +import java.nio.ByteBuffer; + +// A wrapper interface that mimics the new {@link android.media.MediaCodec} +// asynchronous mode API in Lollipop. +public interface AsyncCodec { + public interface Callbacks { + void onInputBufferAvailable(AsyncCodec codec, int index); + + void onOutputBufferAvailable(AsyncCodec codec, int index, BufferInfo info); + + void onError(AsyncCodec codec, int error); + + void onOutputFormatChanged(AsyncCodec codec, MediaFormat format); + } + + public abstract void setCallbacks(Callbacks callbacks, Handler handler); + + public abstract void configure( + MediaFormat format, Surface surface, MediaCrypto crypto, int flags); + + public abstract boolean isAdaptivePlaybackSupported(String mimeType); + + public abstract boolean isTunneledPlaybackSupported(final String mimeType); + + public abstract void start(); + + public abstract void stop(); + + public abstract void flush(); + // Must be called after flush(). + public abstract void resumeReceivingInputs(); + + public abstract void release(); + + public abstract ByteBuffer getInputBuffer(int index); + + public abstract MediaFormat getInputFormat(); + + public abstract ByteBuffer getOutputBuffer(int index); + + public abstract void queueInputBuffer( + int index, int offset, int size, long presentationTimeUs, int flags); + + public abstract void setBitrate(int bps); + + public abstract void queueSecureInputBuffer( + int index, int offset, CryptoInfo info, long presentationTimeUs, int flags); + + public abstract void releaseOutputBuffer(int index, boolean render); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.java new file mode 100644 index 0000000000..3295919b91 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/AsyncCodecFactory.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.media; + +import android.os.Build; +import java.io.IOException; + +public final class AsyncCodecFactory { + public static AsyncCodec create(final String name) throws IOException { + // A bug that getInputBuffer() could fail after flush() then start() wasn't fixed until MR1. + // See: + // https://android.googlesource.com/platform/frameworks/av/+/d9e0603a1be07dbb347c55050c7d4629ea7492e8 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1 + ? new LollipopAsyncCodec(name) + : new JellyBeanAsyncCodec(name); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java new file mode 100644 index 0000000000..d9556d545d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/BaseHlsPlayer.java @@ -0,0 +1,104 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import java.util.concurrent.ConcurrentLinkedQueue; + +public interface BaseHlsPlayer { + + public enum TrackType { + UNDEFINED, + AUDIO, + VIDEO, + TEXT, + } + + public enum ResourceError { + BASE(-100), + UNKNOWN(-101), + PLAYER(-102), + UNSUPPORTED(-103); + + private int mNumVal; + + private ResourceError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + public enum DemuxerError { + BASE(-200), + UNKNOWN(-201), + PLAYER(-202), + UNSUPPORTED(-203); + + private int mNumVal; + + private DemuxerError(final int numVal) { + mNumVal = numVal; + } + + public int code() { + return mNumVal; + } + } + + public interface DemuxerCallbacks { + void onInitialized(boolean hasAudio, boolean hasVideo); + + void onError(int errorCode); + } + + public interface ResourceCallbacks { + void onLoad(String mediaUrl); + + void onDataArrived(); + + void onError(int errorCode); + } + + // Used to identify player instance. + public int getId(); + + // ======================================================================= + // API for GeckoHLSResourceWrapper + // ======================================================================= + public void init(String url, ResourceCallbacks callback); + + public boolean isLiveStream(); + + // ======================================================================= + // API for GeckoHLSDemuxerWrapper + // ======================================================================= + public void addDemuxerWrapperCallbackListener(DemuxerCallbacks callback); + + public ConcurrentLinkedQueue<GeckoHLSSample> getSamples(TrackType trackType, int number); + + public long getBufferedPosition(); + + public int getNumberOfTracks(TrackType trackType); + + public GeckoVideoInfo getVideoInfo(int index); + + public GeckoAudioInfo getAudioInfo(int index); + + public boolean seek(long positionUs); + + public long getNextKeyFrameTime(); + + public void suspend(); + + public void resume(); + + public void play(); + + public void pause(); + + public void release(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java new file mode 100644 index 0000000000..2d39e08406 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Codec.java @@ -0,0 +1,710 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecInfo.VideoCapabilities; +import android.media.MediaCodecList; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import android.view.Surface; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.Queue; +import org.mozilla.gecko.gfx.GeckoSurface; + +/* package */ final class Codec extends ICodec.Stub implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteCodec"; + private static final boolean DEBUG = false; + public static final String SW_CODEC_PREFIX = "OMX.google."; + + public enum Error { + DECODE, + FATAL + } + + private final class Callbacks implements AsyncCodec.Callbacks { + @Override + public void onInputBufferAvailable(final AsyncCodec codec, final int index) { + mInputProcessor.onBuffer(index); + } + + @Override + public void onOutputBufferAvailable( + final AsyncCodec codec, final int index, final MediaCodec.BufferInfo info) { + mOutputProcessor.onBuffer(index, info); + } + + @Override + public void onError(final AsyncCodec codec, final int error) { + reportError(Error.FATAL, new Exception("codec error:" + error)); + } + + @Override + public void onOutputFormatChanged(final AsyncCodec codec, final MediaFormat format) { + mOutputProcessor.onFormatChanged(format); + } + } + + private static final class Input { + public final Sample sample; + public boolean reported; + + public Input(final Sample sample) { + this.sample = sample; + } + } + + private final class InputProcessor { + private boolean mHasInputCapacitySet; + private Queue<Integer> mAvailableInputBuffers = new LinkedList<>(); + private Queue<Sample> mDequeuedSamples = new LinkedList<>(); + private Queue<Input> mInputSamples = new LinkedList<>(); + private boolean mStopped; + + private synchronized Sample onAllocate(final int size) { + final Sample sample = mSamplePool.obtainInput(size); + sample.session = mSession; + mDequeuedSamples.add(sample); + return sample; + } + + private synchronized void onSample(final Sample sample) { + if (sample == null) { + // Ignore empty input. + mSamplePool.recycleInput(mDequeuedSamples.remove()); + Log.w(LOGTAG, "WARN: empty input sample"); + return; + } + + if (sample.isEOS()) { + queueSample(sample); + return; + } + + if (sample.session >= mSession) { + final Sample dequeued = mDequeuedSamples.remove(); + dequeued.setBufferInfo(sample.info); + dequeued.setCryptoInfo(sample.cryptoInfo); + queueSample(dequeued); + } + + sample.dispose(); + } + + private void queueSample(final Sample sample) { + if (!mInputSamples.offer(new Input(sample))) { + reportError(Error.FATAL, new Exception("FAIL: input sample queue is full")); + return; + } + + try { + feedSampleToBuffer(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + private synchronized void onBuffer(final int index) { + if (mStopped || !isValidBuffer(index)) { + return; + } + + if (!mHasInputCapacitySet) { + final int capacity = mCodec.getInputBuffer(index).capacity(); + if (capacity > 0) { + mSamplePool.setInputBufferSize(capacity); + mHasInputCapacitySet = true; + } + } + + if (mAvailableInputBuffers.offer(index)) { + feedSampleToBuffer(); + } else { + reportError(Error.FATAL, new Exception("FAIL: input buffer queue is full")); + } + } + + private boolean isValidBuffer(final int index) { + try { + return mCodec.getInputBuffer(index) != null; + } catch (final IllegalStateException e) { + if (DEBUG) { + Log.d(LOGTAG, "invalid input buffer#" + index, e); + } + return false; + } + } + + private void feedSampleToBuffer() { + while (!mAvailableInputBuffers.isEmpty() && !mInputSamples.isEmpty()) { + final int index = mAvailableInputBuffers.poll(); + if (!isValidBuffer(index)) { + continue; + } + int len = 0; + final Sample sample = mInputSamples.poll().sample; + final long pts = sample.info.presentationTimeUs; + final int flags = sample.info.flags; + final MediaCodec.CryptoInfo cryptoInfo = sample.cryptoInfo; + if (!sample.isEOS() && sample.bufferId != Sample.NO_BUFFER) { + len = sample.info.size; + final ByteBuffer buf = mCodec.getInputBuffer(index); + try { + mSamplePool + .getInputBuffer(sample.bufferId) + .writeToByteBuffer(buf, sample.info.offset, len); + } catch (final IOException e) { + e.printStackTrace(); + len = 0; + } + mSamplePool.recycleInput(sample); + } + + try { + if (cryptoInfo != null && len > 0) { + mCodec.queueSecureInputBuffer(index, 0, cryptoInfo, pts, flags); + } else { + mCodec.queueInputBuffer(index, 0, len, pts, flags); + } + mCallbacks.onInputQueued(pts); + } catch (final RemoteException e) { + e.printStackTrace(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + return; + } + } + reportPendingInputs(); + } + + private void reportPendingInputs() { + try { + for (final Input i : mInputSamples) { + if (!i.reported) { + i.reported = true; + mCallbacks.onInputPending(i.sample.info.presentationTimeUs); + } + } + } catch (final RemoteException e) { + e.printStackTrace(); + } + } + + private synchronized void reset() { + for (final Input i : mInputSamples) { + if (!i.sample.isEOS()) { + mSamplePool.recycleInput(i.sample); + } + } + mInputSamples.clear(); + + for (final Sample s : mDequeuedSamples) { + mSamplePool.recycleInput(s); + } + mDequeuedSamples.clear(); + + mAvailableInputBuffers.clear(); + } + + private synchronized void start() { + if (!mStopped) { + return; + } + mStopped = false; + } + + private synchronized void stop() { + if (mStopped) { + return; + } + mStopped = true; + reset(); + } + } + + private static final class Output { + public final Sample sample; + public final int index; + + public Output(final Sample sample, final int index) { + this.sample = sample; + this.index = index; + } + } + + private class OutputProcessor { + private final boolean mRenderToSurface; + private boolean mHasOutputCapacitySet; + private Queue<Output> mSentOutputs = new LinkedList<>(); + private boolean mStopped; + + private OutputProcessor(final boolean renderToSurface) { + mRenderToSurface = renderToSurface; + } + + private synchronized void onBuffer(final int index, final MediaCodec.BufferInfo info) { + if (mStopped || !isValidBuffer(index)) { + return; + } + + try { + final Sample output = obtainOutputSample(index, info); + mSentOutputs.add(new Output(output, index)); + output.session = mSession; + mCallbacks.onOutput(output); + } catch (final Exception e) { + e.printStackTrace(); + mCodec.releaseOutputBuffer(index, false); + } + + final boolean eos = (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + if (DEBUG && eos) { + Log.d(LOGTAG, "output EOS"); + } + } + + private boolean isValidBuffer(final int index) { + try { + return (mCodec.getOutputBuffer(index) != null) || mRenderToSurface; + } catch (final IllegalStateException e) { + if (DEBUG) { + Log.e(LOGTAG, "invalid buffer#" + index, e); + } + return false; + } + } + + private Sample obtainOutputSample(final int index, final MediaCodec.BufferInfo info) { + final Sample sample = mSamplePool.obtainOutput(info); + + if (mRenderToSurface) { + return sample; + } + + final ByteBuffer output = mCodec.getOutputBuffer(index); + if (!mHasOutputCapacitySet) { + final int capacity = output.capacity(); + if (capacity > 0) { + mSamplePool.setOutputBufferSize(capacity); + mHasOutputCapacitySet = true; + } + } + + if (info.size > 0) { + try { + mSamplePool + .getOutputBuffer(sample.bufferId) + .readFromByteBuffer(output, info.offset, info.size); + } catch (final IOException e) { + Log.e(LOGTAG, "Fail to read output buffer:" + e.getMessage()); + } + } + + return sample; + } + + private synchronized void onRelease(final Sample sample, final boolean render) { + final Output output = mSentOutputs.poll(); + if (output != null) { + mCodec.releaseOutputBuffer(output.index, render); + mSamplePool.recycleOutput(output.sample); + } else if (DEBUG) { + Log.d(LOGTAG, sample + " already released"); + } + + sample.dispose(); + } + + private synchronized void onFormatChanged(final MediaFormat format) { + if (mStopped) { + return; + } + try { + mCallbacks.onOutputFormatChanged(new FormatParam(format)); + } catch (final RemoteException re) { + // Dead recipient. + re.printStackTrace(); + } + } + + private synchronized void reset() { + for (final Output o : mSentOutputs) { + mCodec.releaseOutputBuffer(o.index, false); + mSamplePool.recycleOutput(o.sample); + } + mSentOutputs.clear(); + } + + private synchronized void start() { + if (!mStopped) { + return; + } + mStopped = false; + } + + private synchronized void stop() { + if (mStopped) { + return; + } + mStopped = true; + reset(); + } + } + + private volatile ICodecCallbacks mCallbacks; + private GeckoSurface mSurface; + private AsyncCodec mCodec; + private InputProcessor mInputProcessor; + private OutputProcessor mOutputProcessor; + private long mSession; + private SamplePool mSamplePool; + // Values will be updated after configure called. + private volatile boolean mIsAdaptivePlaybackSupported = false; + private volatile boolean mIsHardwareAccelerated = false; + private boolean mIsTunneledPlaybackSupported = false; + + public synchronized void setCallbacks(final ICodecCallbacks callbacks) throws RemoteException { + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Callbacks is dead"); + try { + release(); + } catch (final RemoteException e) { + // Nowhere to report the error. + } + } + + @Override + public synchronized boolean configure( + final FormatParam format, final GeckoSurface surface, final int flags, final String drmStubId) + throws RemoteException { + if (mCallbacks == null) { + Log.e(LOGTAG, "FAIL: callbacks must be set before calling configure()"); + return false; + } + + if (mCodec != null) { + if (DEBUG) { + Log.d(LOGTAG, "release existing codec: " + mCodec); + } + mCodec.release(); + } + + if (DEBUG) { + Log.d(LOGTAG, "configure " + this); + } + + final MediaFormat fmt = format.asFormat(); + final String mime = fmt.getString(MediaFormat.KEY_MIME); + if (mime == null || mime.isEmpty()) { + Log.e(LOGTAG, "invalid MIME type: " + mime); + return false; + } + + final List<String> found = + findMatchingCodecNames(fmt, flags == MediaCodec.CONFIGURE_FLAG_ENCODE); + for (final String name : found) { + final AsyncCodec codec = configureCodec(name, fmt, surface, flags, drmStubId); + if (codec == null) { + Log.w(LOGTAG, "unable to configure " + name + ". Try next."); + continue; + } + mIsHardwareAccelerated = !name.startsWith(SW_CODEC_PREFIX); + mCodec = codec; + // Bug 1789846: Check if the Codec provides stride or height values to use. + if (flags == MediaCodec.CONFIGURE_FLAG_ENCODE && fmt.containsKey(MediaFormat.KEY_WIDTH)) { + final MediaFormat inputFormat = mCodec.getInputFormat(); + if (inputFormat != null) { + if (inputFormat.containsKey(MediaFormat.KEY_STRIDE)) { + fmt.setInteger(MediaFormat.KEY_STRIDE, inputFormat.getInteger(MediaFormat.KEY_STRIDE)); + } + if (inputFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + fmt.setInteger( + MediaFormat.KEY_SLICE_HEIGHT, inputFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT)); + } + } + } + mInputProcessor = new InputProcessor(); + final boolean renderToSurface = surface != null; + mOutputProcessor = new OutputProcessor(renderToSurface); + mSamplePool = new SamplePool(name, renderToSurface); + if (renderToSurface) { + mIsTunneledPlaybackSupported = mCodec.isTunneledPlaybackSupported(mime); + mSurface = surface; // Take ownership of surface. + } + if (DEBUG) { + Log.d(LOGTAG, codec.toString() + " created. Render to surface?" + renderToSurface); + } + return true; + } + + return false; + } + + private List<String> findMatchingCodecNames(final MediaFormat format, final boolean isEncoder) { + final String mimeType = format.getString(MediaFormat.KEY_MIME); + // Missing width and height value in format means audio; + // Video format should never has 0 width or height. + final int width = + format.containsKey(MediaFormat.KEY_WIDTH) ? format.getInteger(MediaFormat.KEY_WIDTH) : 0; + final int height = + format.containsKey(MediaFormat.KEY_HEIGHT) ? format.getInteger(MediaFormat.KEY_HEIGHT) : 0; + + final int numCodecs = MediaCodecList.getCodecCount(); + final List<String> found = new ArrayList<>(); + for (int i = 0; i < numCodecs; i++) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder() == !isEncoder) { + continue; + } + + final String[] types = info.getSupportedTypes(); + for (final String t : types) { + if (!t.equalsIgnoreCase(mimeType)) { + continue; + } + final String name = info.getName(); + // API 21+ provide a method to query whether supplied size is supported. For + // older version, just avoid software video encoders. + if (isEncoder && width > 0 && height > 0) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + final VideoCapabilities c = + info.getCapabilitiesForType(mimeType).getVideoCapabilities(); + if (c != null && !c.isSizeSupported(width, height)) { + if (DEBUG) { + Log.d(LOGTAG, name + ": " + width + "x" + height + " not supported"); + } + continue; + } + } else if (name.startsWith(SW_CODEC_PREFIX)) { + continue; + } + } + + found.add(name); + if (DEBUG) { + Log.d( + LOGTAG, + "found " + (isEncoder ? "encoder:" : "decoder:") + name + " for mime:" + mimeType); + } + } + } + return found; + } + + private AsyncCodec configureCodec( + final String name, + final MediaFormat format, + final Surface surface, + final int flags, + final String drmStubId) { + try { + final AsyncCodec codec = AsyncCodecFactory.create(name); + codec.setCallbacks(new Callbacks(), null); + + final MediaCrypto crypto = RemoteMediaDrmBridgeStub.getMediaCrypto(drmStubId); + if (DEBUG) { + Log.d( + LOGTAG, + "configure mediacodec with crypto(" + (crypto != null) + ") / Id :" + drmStubId); + } + + if (surface != null) { + setupAdaptivePlayback(codec, format); + } + + codec.configure(format, surface, crypto, flags); + return codec; + } catch (final Exception e) { + Log.e(LOGTAG, "codec creation error", e); + return null; + } + } + + private void setupAdaptivePlayback(final AsyncCodec codec, final MediaFormat format) { + // Video decoder should config with adaptive playback capability. + mIsAdaptivePlaybackSupported = + codec.isAdaptivePlaybackSupported(format.getString(MediaFormat.KEY_MIME)); + if (mIsAdaptivePlaybackSupported) { + if (DEBUG) { + Log.d(LOGTAG, "codec supports adaptive playback = " + mIsAdaptivePlaybackSupported); + } + // TODO: may need to find a way to not use hard code to decide the max w/h. + format.setInteger(MediaFormat.KEY_MAX_WIDTH, 1920); + format.setInteger(MediaFormat.KEY_MAX_HEIGHT, 1080); + } + } + + @Override + public synchronized boolean isAdaptivePlaybackSupported() { + return mIsAdaptivePlaybackSupported; + } + + @Override + public synchronized boolean isHardwareAccelerated() { + return mIsHardwareAccelerated; + } + + @Override + public synchronized boolean isTunneledPlaybackSupported() { + return mIsTunneledPlaybackSupported; + } + + @Override + public synchronized void start() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "start " + this); + } + mInputProcessor.start(); + mOutputProcessor.start(); + try { + mCodec.start(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + private void reportError(final Error error, final Exception e) { + if (e != null) { + e.printStackTrace(); + } + try { + mCallbacks.onError(error == Error.FATAL); + } catch (final NullPointerException ne) { + // mCallbacks has been disposed by release(). + } catch (final RemoteException re) { + re.printStackTrace(); + } + } + + @Override + public synchronized void stop() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "stop " + this); + } + try { + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.stop(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void flush() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "flush " + this); + } + try { + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.flush(); + if (DEBUG) { + Log.d(LOGTAG, "flushed " + this); + } + mInputProcessor.start(); + mOutputProcessor.start(); + mCodec.resumeReceivingInputs(); + mSession++; + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized Sample dequeueInput(final int size) throws RemoteException { + try { + return mInputProcessor.onAllocate(size); + } catch (final Exception e) { + // Translate allocation error to remote exception. + throw new RemoteException(e.getMessage()); + } + } + + @Override + public synchronized SampleBuffer getInputBuffer(final int id) { + if (mSamplePool == null) { + return null; + } + return mSamplePool.getInputBuffer(id); + } + + @Override + public synchronized SampleBuffer getOutputBuffer(final int id) { + if (mSamplePool == null) { + return null; + } + return mSamplePool.getOutputBuffer(id); + } + + @Override + public synchronized void queueInput(final Sample sample) throws RemoteException { + try { + mInputProcessor.onSample(sample); + } catch (final Exception e) { + throw new RemoteException(e.getMessage()); + } + } + + @Override + public synchronized void setBitrate(final int bps) { + try { + mCodec.setBitrate(bps); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void releaseOutput(final Sample sample, final boolean render) { + try { + mOutputProcessor.onRelease(sample, render); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + } + + @Override + public synchronized void release() throws RemoteException { + if (DEBUG) { + Log.d(LOGTAG, "release " + this); + } + try { + // In case Codec.stop() is not called yet. + mInputProcessor.stop(); + mOutputProcessor.stop(); + + mCodec.release(); + } catch (final Exception e) { + reportError(Error.FATAL, e); + } + mCodec = null; + mSamplePool.reset(); + mSamplePool = null; + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + if (mSurface != null) { + mSurface.release(); + mSurface = null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java new file mode 100644 index 0000000000..e31ea4b132 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/CodecProxy.java @@ -0,0 +1,508 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.media.MediaFormat; +import android.os.Build; +import android.os.DeadObjectException; +import android.os.RemoteException; +import android.util.Log; +import android.util.SparseArray; +import androidx.annotation.RequiresApi; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.GeckoSurface; +import org.mozilla.gecko.mozglue.JNIObject; + +// Proxy class of ICodec binder. +public final class CodecProxy { + private static final String LOGTAG = "GeckoRemoteCodecProxy"; + private static final boolean DEBUG = false; + @WrapForJNI private static final long INVALID_SESSION = -1; + + private ICodec mRemote; + private long mSession; + private boolean mIsEncoder; + private FormatParam mFormat; + private GeckoSurface mOutputSurface; + private CallbacksForwarder mCallbacks; + private String mRemoteDrmStubId; + private Queue<Sample> mSurfaceOutputs = new ConcurrentLinkedQueue<>(); + private boolean mFlushed = true; + + private SparseArray<SampleBuffer> mInputBuffers = new SparseArray<>(); + private SparseArray<SampleBuffer> mOutputBuffers = new SparseArray<>(); + + public interface Callbacks { + void onInputStatus(long timestamp, boolean processed); + + void onOutputFormatChanged(MediaFormat format); + + void onOutput(Sample output, SampleBuffer buffer); + + void onError(boolean fatal); + } + + @WrapForJNI + public static class NativeCallbacks extends JNIObject implements Callbacks { + public native void onInputStatus(long timestamp, boolean processed); + + public native void onOutputFormatChanged(MediaFormat format); + + public native void onOutput(Sample output, SampleBuffer buffer); + + public native void onError(boolean fatal); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } + + private class CallbacksForwarder extends ICodecCallbacks.Stub { + private final Callbacks mCallbacks; + private boolean mCodecProxyReleased; + + CallbacksForwarder(final Callbacks callbacks) { + mCallbacks = callbacks; + } + + @Override + public synchronized void onInputQueued(final long timestamp) throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onInputStatus(timestamp, true /* processed */); + } + } + + @Override + public synchronized void onInputPending(final long timestamp) throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onInputStatus(timestamp, false /* processed */); + } + } + + @Override + public synchronized void onOutputFormatChanged(final FormatParam format) + throws RemoteException { + if (!mCodecProxyReleased) { + mCallbacks.onOutputFormatChanged(format.asFormat()); + } + } + + @Override + public synchronized void onOutput(final Sample sample) throws RemoteException { + if (mCodecProxyReleased) { + sample.dispose(); + return; + } + + final SampleBuffer buffer = CodecProxy.this.getOutputBuffer(sample.bufferId); + if (mOutputSurface != null) { + // Don't render to surface just yet. Callback will make that happen when it's time. + mSurfaceOutputs.offer(sample); + } else if (buffer == null) { + // Buffer with given ID has been flushed. + sample.dispose(); + return; + } + mCallbacks.onOutput(sample, buffer); + } + + @Override + public void onError(final boolean fatal) throws RemoteException { + reportError(fatal); + } + + private synchronized void reportError(final boolean fatal) { + if (!mCodecProxyReleased) { + mCallbacks.onError(fatal); + } + } + + private synchronized void setCodecProxyReleased() { + mCodecProxyReleased = true; + } + } + + @WrapForJNI + public int GetInputFormatStride() { + if (mFormat.asFormat().containsKey(MediaFormat.KEY_STRIDE)) { + return mFormat.asFormat().getInteger(MediaFormat.KEY_STRIDE); + } + return 0; + } + + @WrapForJNI + public int GetInputFormatYPlaneHeight() { + if (mFormat.asFormat().containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + return mFormat.asFormat().getInteger(MediaFormat.KEY_SLICE_HEIGHT); + } + return 0; + } + + @WrapForJNI + public static CodecProxy create( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + return RemoteManager.getInstance() + .createCodec(isEncoder, format, surface, callbacks, drmStubId); + } + + public static CodecProxy createCodecProxy( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + return new CodecProxy(isEncoder, format, surface, callbacks, drmStubId); + } + + private CodecProxy( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final Callbacks callbacks, + final String drmStubId) { + mIsEncoder = isEncoder; + mFormat = new FormatParam(format); + mOutputSurface = surface; + mRemoteDrmStubId = drmStubId; + mCallbacks = new CallbacksForwarder(callbacks); + } + + boolean init(final ICodec remote) { + try { + remote.setCallbacks(mCallbacks); + if (!remote.configure( + mFormat, + mOutputSurface, + mIsEncoder ? MediaCodec.CONFIGURE_FLAG_ENCODE : 0, + mRemoteDrmStubId)) { + return false; + } + remote.start(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + + mRemote = remote; + return true; + } + + boolean deinit() { + try { + mRemote.stop(); + mRemote.release(); + mRemote = null; + return true; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isAdaptivePlaybackSupported() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isAdaptivePlaybackSupported with an ended codec"); + return false; + } + try { + return mRemote.isAdaptivePlaybackSupported(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isHardwareAccelerated() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isHardwareAccelerated with an ended codec"); + return false; + } + try { + return mRemote.isHardwareAccelerated(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized boolean isTunneledPlaybackSupported() { + if (mRemote == null) { + Log.e(LOGTAG, "cannot check isTunneledPlaybackSupported with an ended codec"); + return false; + } + try { + return mRemote.isTunneledPlaybackSupported(); + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + } + + @WrapForJNI + public synchronized long input( + final ByteBuffer bytes, final BufferInfo info, final CryptoInfo cryptoInfo) { + if (mRemote == null) { + Log.e(LOGTAG, "cannot send input to an ended codec"); + return INVALID_SESSION; + } + + final boolean eos = info.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM; + + if (eos) { + return sendInput(Sample.EOS); + } + + try { + final Sample s = mRemote.dequeueInput(info.size); + fillInputBuffer(s.bufferId, bytes, info.offset, info.size); + mSession = s.session; + return sendInput(s.set(info, cryptoInfo)); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "fail to dequeue input buffer", e); + } catch (final IOException e) { + Log.e(LOGTAG, "fail to copy input data.", e); + // Balance dequeue/queue. + sendInput(null); + } + return INVALID_SESSION; + } + + private void fillInputBuffer( + final int bufferId, final ByteBuffer bytes, final int offset, final int size) + throws RemoteException, IOException { + if (bytes == null || size == 0) { + Log.w(LOGTAG, "empty input"); + return; + } + + SampleBuffer buffer = mInputBuffers.get(bufferId); + if (buffer == null) { + buffer = mRemote.getInputBuffer(bufferId); + if (buffer != null) { + mInputBuffers.put(bufferId, buffer); + } + } + + if (buffer.capacity() < size) { + final IOException e = + new IOException("data larger than capacity: " + size + " > " + buffer.capacity()); + Log.e(LOGTAG, "cannot fill input.", e); + throw e; + } + + buffer.readFromByteBuffer(bytes, offset, size); + } + + private long sendInput(final Sample sample) { + try { + mRemote.queueInput(sample); + if (sample != null) { + sample.dispose(); + mFlushed = false; + } + } catch (final Exception e) { + Log.e(LOGTAG, "fail to queue input:" + sample, e); + return INVALID_SESSION; + } + return mSession; + } + + @WrapForJNI + public synchronized boolean flush() { + if (mFlushed) { + return true; + } + if (mRemote == null) { + Log.e(LOGTAG, "cannot flush an ended codec"); + return false; + } + try { + if (DEBUG) { + Log.d(LOGTAG, "flush " + this); + } + resetBuffers(); + mRemote.flush(); + mFlushed = true; + } catch (final DeadObjectException e) { + return false; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + + private void resetBuffers() { + for (int i = 0; i < mInputBuffers.size(); ++i) { + mInputBuffers.valueAt(i).dispose(); + } + mInputBuffers.clear(); + for (int i = 0; i < mOutputBuffers.size(); ++i) { + mOutputBuffers.valueAt(i).dispose(); + } + mOutputBuffers.clear(); + } + + @WrapForJNI + public boolean release() { + mCallbacks.setCodecProxyReleased(); + synchronized (this) { + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + return true; + } + if (DEBUG) { + Log.d(LOGTAG, "release " + this); + } + + if (!mSurfaceOutputs.isEmpty()) { + // Flushing output buffers to surface may cause some frames to be skipped and + // should not happen unless caller release codec before processing all buffers. + Log.w(LOGTAG, "release codec when " + mSurfaceOutputs.size() + " output buffers unhandled"); + try { + for (final Sample s : mSurfaceOutputs) { + mRemote.releaseOutput(s, true); + } + } catch (final RemoteException e) { + e.printStackTrace(); + } + mSurfaceOutputs.clear(); + } + + resetBuffers(); + + try { + RemoteManager.getInstance().releaseCodec(this); + } catch (final DeadObjectException e) { + return false; + } catch (final RemoteException e) { + e.printStackTrace(); + return false; + } + return true; + } + } + + @WrapForJNI + public synchronized boolean setBitrate(final int bps) { + if (!mIsEncoder) { + Log.w(LOGTAG, "this api is encoder-only"); + return false; + } + + if (android.os.Build.VERSION.SDK_INT < 19) { + Log.w(LOGTAG, "this api was added in API level 19"); + return false; + } + + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + return true; + } + + try { + mRemote.setBitrate(bps); + } catch (final RemoteException e) { + Log.e(LOGTAG, "remote fail to set rates:" + bps); + e.printStackTrace(); + } + return true; + } + + @WrapForJNI + public synchronized boolean releaseOutput(final Sample sample, final boolean render) { + if (mOutputSurface != null) { + if (!mSurfaceOutputs.remove(sample)) { + if (mRemote != null) Log.w(LOGTAG, "already released: " + sample); + return true; + } + + if (DEBUG && !render) { + Log.d(LOGTAG, "drop output:" + sample.info.presentationTimeUs); + } + } + + if (mRemote == null) { + Log.w(LOGTAG, "codec already ended"); + sample.dispose(); + return true; + } + + try { + mRemote.releaseOutput(sample, render); + } catch (final RemoteException e) { + Log.e(LOGTAG, "remote fail to release output:" + sample.info.presentationTimeUs); + e.printStackTrace(); + } + sample.dispose(); + + return true; + } + + /* package */ void reportError(final boolean fatal) { + mCallbacks.reportError(fatal); + } + + private synchronized SampleBuffer getOutputBuffer(final int id) { + if (mRemote == null) { + Log.e(LOGTAG, "cannot get buffer#" + id + " from an ended codec"); + return null; + } + + if (mOutputSurface != null || id == Sample.NO_BUFFER) { + return null; + } + + SampleBuffer buffer = mOutputBuffers.get(id); + if (buffer != null) { + return buffer; + } + + try { + buffer = mRemote.getOutputBuffer(id); + } catch (final Exception e) { + Log.e(LOGTAG, "cannot get buffer#" + id, e); + return null; + } + if (buffer != null) { + mOutputBuffers.put(id, buffer); + } + + return buffer; + } + + @WrapForJNI + public static boolean supportsCBCS() { + // Android N/API-24 supports CBCS but there seems to be a bug. + // See https://github.com/google/ExoPlayer/issues/4022 + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1; + } + + @RequiresApi(api = Build.VERSION_CODES.N_MR1) + @WrapForJNI + public static boolean setCryptoPatternIfNeeded( + final CryptoInfo info, final int blocksToEncrypt, final int blocksToSkip) { + if (supportsCBCS() && (blocksToEncrypt > 0 || blocksToSkip > 0)) { + info.setPattern(new CryptoInfo.Pattern(blocksToEncrypt, blocksToSkip)); + return true; + } + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java new file mode 100644 index 0000000000..03340530ee --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/FormatParam.java @@ -0,0 +1,178 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaFormat; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import java.nio.ByteBuffer; + +/** + * A wrapper to make {@link MediaFormat} parcelable. Supports following keys: + * + * <ul> + * <li>{@link MediaFormat#KEY_MIME} + * <li>{@link MediaFormat#KEY_WIDTH} + * <li>{@link MediaFormat#KEY_HEIGHT} + * <li>{@link MediaFormat#KEY_CHANNEL_COUNT} + * <li>{@link MediaFormat#KEY_SAMPLE_RATE} + * <li>{@link MediaFormat#KEY_BIT_RATE} + * <li>{@link MediaFormat#KEY_BITRATE_MODE} + * <li>{@link MediaFormat#KEY_COLOR_FORMAT} + * <li>{@link MediaFormat#KEY_FRAME_RATE} + * <li>{@link MediaFormat#KEY_I_FRAME_INTERVAL} + * <li>{@link MediaFormat#KEY_STRIDE} + * <li>{@link MediaFormat#KEY_SLICE_HEIGHT} + * <li>"csd-0" + * <li>"csd-1" + * </ul> + */ +public final class FormatParam implements Parcelable { + // Keys for codec specific config bits not exposed in {@link MediaFormat}. + private static final String KEY_CONFIG_0 = "csd-0"; + private static final String KEY_CONFIG_1 = "csd-1"; + + private MediaFormat mFormat; + + public MediaFormat asFormat() { + return mFormat; + } + + public FormatParam(final MediaFormat format) { + mFormat = format; + } + + protected FormatParam(final Parcel in) { + mFormat = new MediaFormat(); + readFromParcel(in); + } + + public static final Creator<FormatParam> CREATOR = + new Creator<FormatParam>() { + @Override + public FormatParam createFromParcel(final Parcel in) { + return new FormatParam(in); + } + + @Override + public FormatParam[] newArray(final int size) { + return new FormatParam[size]; + } + }; + + @Override + public int describeContents() { + return 0; + } + + public void readFromParcel(final Parcel in) { + final Bundle bundle = in.readBundle(); + fromBundle(bundle); + } + + private void fromBundle(final Bundle bundle) { + if (bundle.containsKey(MediaFormat.KEY_MIME)) { + mFormat.setString(MediaFormat.KEY_MIME, bundle.getString(MediaFormat.KEY_MIME)); + } + if (bundle.containsKey(MediaFormat.KEY_WIDTH)) { + mFormat.setInteger(MediaFormat.KEY_WIDTH, bundle.getInt(MediaFormat.KEY_WIDTH)); + } + if (bundle.containsKey(MediaFormat.KEY_HEIGHT)) { + mFormat.setInteger(MediaFormat.KEY_HEIGHT, bundle.getInt(MediaFormat.KEY_HEIGHT)); + } + if (bundle.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + mFormat.setInteger( + MediaFormat.KEY_CHANNEL_COUNT, bundle.getInt(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (bundle.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + mFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, bundle.getInt(MediaFormat.KEY_SAMPLE_RATE)); + } + if (bundle.containsKey(KEY_CONFIG_0)) { + mFormat.setByteBuffer(KEY_CONFIG_0, ByteBuffer.wrap(bundle.getByteArray(KEY_CONFIG_0))); + } + if (bundle.containsKey(KEY_CONFIG_1)) { + mFormat.setByteBuffer(KEY_CONFIG_1, ByteBuffer.wrap(bundle.getByteArray((KEY_CONFIG_1)))); + } + if (bundle.containsKey(MediaFormat.KEY_BIT_RATE)) { + mFormat.setInteger(MediaFormat.KEY_BIT_RATE, bundle.getInt(MediaFormat.KEY_BIT_RATE)); + } + if (bundle.containsKey(MediaFormat.KEY_BITRATE_MODE)) { + mFormat.setInteger(MediaFormat.KEY_BITRATE_MODE, bundle.getInt(MediaFormat.KEY_BITRATE_MODE)); + } + if (bundle.containsKey(MediaFormat.KEY_COLOR_FORMAT)) { + mFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT, bundle.getInt(MediaFormat.KEY_COLOR_FORMAT)); + } + if (bundle.containsKey(MediaFormat.KEY_FRAME_RATE)) { + mFormat.setInteger(MediaFormat.KEY_FRAME_RATE, bundle.getInt(MediaFormat.KEY_FRAME_RATE)); + } + if (bundle.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) { + mFormat.setInteger( + MediaFormat.KEY_I_FRAME_INTERVAL, bundle.getInt(MediaFormat.KEY_I_FRAME_INTERVAL)); + } + if (bundle.containsKey(MediaFormat.KEY_STRIDE)) { + mFormat.setInteger(MediaFormat.KEY_STRIDE, bundle.getInt(MediaFormat.KEY_STRIDE)); + } + if (bundle.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + mFormat.setInteger(MediaFormat.KEY_SLICE_HEIGHT, bundle.getInt(MediaFormat.KEY_SLICE_HEIGHT)); + } + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeBundle(toBundle()); + } + + private Bundle toBundle() { + final Bundle bundle = new Bundle(); + if (mFormat.containsKey(MediaFormat.KEY_MIME)) { + bundle.putString(MediaFormat.KEY_MIME, mFormat.getString(MediaFormat.KEY_MIME)); + } + if (mFormat.containsKey(MediaFormat.KEY_WIDTH)) { + bundle.putInt(MediaFormat.KEY_WIDTH, mFormat.getInteger(MediaFormat.KEY_WIDTH)); + } + if (mFormat.containsKey(MediaFormat.KEY_HEIGHT)) { + bundle.putInt(MediaFormat.KEY_HEIGHT, mFormat.getInteger(MediaFormat.KEY_HEIGHT)); + } + if (mFormat.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) { + bundle.putInt( + MediaFormat.KEY_CHANNEL_COUNT, mFormat.getInteger(MediaFormat.KEY_CHANNEL_COUNT)); + } + if (mFormat.containsKey(MediaFormat.KEY_SAMPLE_RATE)) { + bundle.putInt(MediaFormat.KEY_SAMPLE_RATE, mFormat.getInteger(MediaFormat.KEY_SAMPLE_RATE)); + } + if (mFormat.containsKey(KEY_CONFIG_0)) { + final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_0); + bundle.putByteArray(KEY_CONFIG_0, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + if (mFormat.containsKey(KEY_CONFIG_1)) { + final ByteBuffer bytes = mFormat.getByteBuffer(KEY_CONFIG_1); + bundle.putByteArray(KEY_CONFIG_1, Sample.byteArrayFromBuffer(bytes, 0, bytes.capacity())); + } + if (mFormat.containsKey(MediaFormat.KEY_BIT_RATE)) { + bundle.putInt(MediaFormat.KEY_BIT_RATE, mFormat.getInteger(MediaFormat.KEY_BIT_RATE)); + } + if (mFormat.containsKey(MediaFormat.KEY_BITRATE_MODE)) { + bundle.putInt(MediaFormat.KEY_BITRATE_MODE, mFormat.getInteger(MediaFormat.KEY_BITRATE_MODE)); + } + if (mFormat.containsKey(MediaFormat.KEY_COLOR_FORMAT)) { + bundle.putInt(MediaFormat.KEY_COLOR_FORMAT, mFormat.getInteger(MediaFormat.KEY_COLOR_FORMAT)); + } + if (mFormat.containsKey(MediaFormat.KEY_FRAME_RATE)) { + bundle.putInt(MediaFormat.KEY_FRAME_RATE, mFormat.getInteger(MediaFormat.KEY_FRAME_RATE)); + } + if (mFormat.containsKey(MediaFormat.KEY_I_FRAME_INTERVAL)) { + bundle.putInt( + MediaFormat.KEY_I_FRAME_INTERVAL, mFormat.getInteger(MediaFormat.KEY_I_FRAME_INTERVAL)); + } + if (mFormat.containsKey(MediaFormat.KEY_STRIDE)) { + bundle.putInt(MediaFormat.KEY_STRIDE, mFormat.getInteger(MediaFormat.KEY_STRIDE)); + } + if (mFormat.containsKey(MediaFormat.KEY_SLICE_HEIGHT)) { + bundle.putInt(MediaFormat.KEY_SLICE_HEIGHT, mFormat.getInteger(MediaFormat.KEY_SLICE_HEIGHT)); + } + return bundle; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java new file mode 100644 index 0000000000..6418375a57 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoAudioInfo.java @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import org.mozilla.gecko.annotation.WrapForJNI; + +// A subset of the class AudioInfo in dom/media/MediaInfo.h +@WrapForJNI +public final class GeckoAudioInfo { + public final byte[] codecSpecificData; + public final int rate; + public final int channels; + public final int bitDepth; + public final int profile; + public final long duration; + public final String mimeType; + + public GeckoAudioInfo( + final int rate, + final int channels, + final int bitDepth, + final int profile, + final long duration, + final String mimeType, + final byte[] codecSpecificData) { + this.rate = rate; + this.channels = channels; + this.bitDepth = bitDepth; + this.profile = profile; + this.duration = duration; + this.mimeType = mimeType; + this.codecSpecificData = codecSpecificData; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java new file mode 100644 index 0000000000..cd732fe535 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSDemuxerWrapper.java @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +public final class GeckoHLSDemuxerWrapper { + private static final String LOGTAG = "GeckoHLSDemuxerWrapper"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + + // NOTE : These TRACK definitions should be synced with Gecko. + public enum TrackType { + UNDEFINED(0), + AUDIO(1), + VIDEO(2), + TEXT(3); + private int mType; + + private TrackType(final int type) { + mType = type; + } + + public int value() { + return mType; + } + } + + private BaseHlsPlayer mPlayer = null; + + public static class Callbacks extends JNIObject implements BaseHlsPlayer.DemuxerCallbacks { + @WrapForJNI(calledFrom = "gecko") + Callbacks() {} + + @Override + @WrapForJNI + public native void onInitialized(boolean hasAudio, boolean hasVideo); + + @Override + @WrapForJNI + public native void onError(int errorCode); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // Callbacks + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + private BaseHlsPlayer.TrackType getPlayerTrackType(final int trackType) { + if (trackType == TrackType.AUDIO.value()) { + return BaseHlsPlayer.TrackType.AUDIO; + } else if (trackType == TrackType.VIDEO.value()) { + return BaseHlsPlayer.TrackType.VIDEO; + } else if (trackType == TrackType.TEXT.value()) { + return BaseHlsPlayer.TrackType.TEXT; + } + return BaseHlsPlayer.TrackType.UNDEFINED; + } + + @WrapForJNI + public long getBuffered() { + assertTrue(mPlayer != null); + return mPlayer.getBufferedPosition(); + } + + @WrapForJNI(calledFrom = "gecko") + public static GeckoHLSDemuxerWrapper create( + final int id, final BaseHlsPlayer.DemuxerCallbacks callback) { + return new GeckoHLSDemuxerWrapper(id, callback); + } + + @WrapForJNI + public int getNumberOfTracks(final int trackType) { + assertTrue(mPlayer != null); + final int tracks = mPlayer.getNumberOfTracks(getPlayerTrackType(trackType)); + if (DEBUG) Log.d(LOGTAG, "[GetNumberOfTracks] type : " + trackType + ", num = " + tracks); + return tracks; + } + + @WrapForJNI + public GeckoAudioInfo getAudioInfo(final int index) { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "[getAudioInfo] formatIndex : " + index); + final GeckoAudioInfo aInfo = mPlayer.getAudioInfo(index); + return aInfo; + } + + @WrapForJNI + public GeckoVideoInfo getVideoInfo(final int index) { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "[getVideoInfo] formatIndex : " + index); + final GeckoVideoInfo vInfo = mPlayer.getVideoInfo(index); + return vInfo; + } + + @WrapForJNI + public boolean seek(final long seekTime) { + // seekTime : microseconds. + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "seek : " + seekTime + " (Us)"); + return mPlayer.seek(seekTime); + } + + GeckoHLSDemuxerWrapper(final int id, final BaseHlsPlayer.DemuxerCallbacks callback) { + if (DEBUG) Log.d(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ..."); + assertTrue(callback != null); + try { + mPlayer = GeckoPlayerFactory.getPlayer(id); + if (mPlayer != null) { + mPlayer.addDemuxerWrapperCallbackListener(callback); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Constructing GeckoHLSDemuxerWrapper ... error", e); + callback.onError(BaseHlsPlayer.DemuxerError.UNKNOWN.code()); + } + } + + @WrapForJNI + private GeckoHLSSample[] getSamples(final int mediaType, final int number) { + assertTrue(mPlayer != null); + ConcurrentLinkedQueue<GeckoHLSSample> samples = null; + // getA/VSamples will always return a non-null instance. + samples = mPlayer.getSamples(getPlayerTrackType(mediaType), number); + assertTrue(samples.size() <= number); + return samples.toArray(new GeckoHLSSample[samples.size()]); + } + + @WrapForJNI + private long getNextKeyFrameTime() { + assertTrue(mPlayer != null); + return mPlayer.getNextKeyFrameTime(); + } + + @WrapForJNI + private boolean isLiveStream() { + assertTrue(mPlayer != null); + return mPlayer.isLiveStream(); + } + + @WrapForJNI // Called when native object is destroyed. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mPlayer != null) { + release(); + } + } + + private void release() { + assertTrue(mPlayer != null); + if (DEBUG) Log.d(LOGTAG, "release BaseHlsPlayer..."); + GeckoPlayerFactory.removePlayer(mPlayer); + mPlayer.release(); + mPlayer = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java new file mode 100644 index 0000000000..c21789fdd0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSResourceWrapper.java @@ -0,0 +1,119 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +public class GeckoHLSResourceWrapper { + private static final String LOGTAG = "GeckoHLSResourceWrapper"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + private BaseHlsPlayer mPlayer = null; + private boolean mDestroy = false; + + public static class Callbacks extends JNIObject implements BaseHlsPlayer.ResourceCallbacks { + @WrapForJNI(calledFrom = "gecko") + Callbacks() {} + + @Override + @WrapForJNI + public native void onLoad(String mediaUrl); + + @Override + @WrapForJNI + public native void onDataArrived(); + + @Override + @WrapForJNI + public native void onError(int errorCode); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // Callbacks + + private GeckoHLSResourceWrapper( + final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper created with url = " + url); + assertTrue(callback != null); + + mPlayer = GeckoPlayerFactory.getPlayer(); + try { + mPlayer.init(url, callback); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to create GeckoHlsResourceWrapper !", e); + callback.onError(BaseHlsPlayer.ResourceError.UNKNOWN.code()); + } + } + + @WrapForJNI(calledFrom = "gecko") + public static GeckoHLSResourceWrapper create( + final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + return new GeckoHLSResourceWrapper(url, callback); + } + + @WrapForJNI(calledFrom = "gecko") + public int getPlayerId() { + // GeckoHLSResourceWrapper should always be created before others + assertTrue(!mDestroy); + assertTrue(mPlayer != null); + return mPlayer.getId(); + } + + @WrapForJNI(calledFrom = "gecko") + public void suspend() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper suspend"); + if (mPlayer != null) { + mPlayer.suspend(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void resume() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper resume"); + if (mPlayer != null) { + mPlayer.resume(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void play() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement played"); + if (mPlayer != null) { + mPlayer.play(); + } + } + + @WrapForJNI(calledFrom = "gecko") + public void pause() { + if (DEBUG) Log.d(LOGTAG, "GeckoHLSResourceWrapper mediaelement paused"); + if (mPlayer != null) { + mPlayer.pause(); + } + } + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + @WrapForJNI // Called when native object is mDestroy. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mDestroy) { + return; + } + mDestroy = true; + if (mPlayer != null) { + GeckoPlayerFactory.removePlayer(mPlayer); + mPlayer.release(); + mPlayer = null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java new file mode 100644 index 0000000000..d2ab76a13d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHLSSample.java @@ -0,0 +1,93 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class GeckoHLSSample { + public static final GeckoHLSSample EOS; + + static { + final BufferInfo eosInfo = new BufferInfo(); + eosInfo.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + EOS = new GeckoHLSSample(null, eosInfo, null, 0); + } + + // Indicate the index of format which is used by this sample. + @WrapForJNI public final int formatIndex; + + @WrapForJNI public long duration; + + @WrapForJNI public final BufferInfo info; + + @WrapForJNI public final CryptoInfo cryptoInfo; + + private ByteBuffer mBuffer = null; + + @WrapForJNI + public void writeToByteBuffer(final ByteBuffer dest) throws IOException { + if (mBuffer != null && dest != null && info.size > 0) { + dest.put(mBuffer); + } + } + + @WrapForJNI + public boolean isEOS() { + return (info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + } + + @WrapForJNI + public boolean isKeyFrame() { + return (info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0; + } + + public static GeckoHLSSample create( + final ByteBuffer src, + final BufferInfo info, + final CryptoInfo cryptoInfo, + final int formatIndex) { + return new GeckoHLSSample(src, info, cryptoInfo, formatIndex); + } + + private GeckoHLSSample( + final ByteBuffer buffer, + final BufferInfo info, + final CryptoInfo cryptoInfo, + final int formatIndex) { + this.formatIndex = formatIndex; + duration = Long.MAX_VALUE; + this.mBuffer = buffer; + this.info = info; + this.cryptoInfo = cryptoInfo; + } + + @Override + public String toString() { + if (isEOS()) { + return "EOS GeckoHLSSample"; + } + + final StringBuilder str = new StringBuilder(); + str.append("{ info=") + .append("{ offset=") + .append(info.offset) + .append(", size=") + .append(info.size) + .append(", pts=") + .append(info.presentationTimeUs) + .append(", duration=") + .append(duration) + .append(", flags=") + .append(Integer.toHexString(info.flags)) + .append(" }") + .append(" }"); + return str.toString(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java new file mode 100644 index 0000000000..a666e0e860 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsAudioRenderer.java @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Build; +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.List; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +public class GeckoHlsAudioRenderer extends GeckoHlsRendererBase { + public GeckoHlsAudioRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) { + super(C.TRACK_TYPE_AUDIO, eventDispatcher); + assertTrue(Build.VERSION.SDK_INT >= 16); + LOGTAG = getClass().getSimpleName(); + DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + } + + @Override + public final int supportsFormat(final Format format) { + /* + * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering + * formats with the same mime type, but + * the properties of the format exceed + * the renderer's capability. + * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose + * renderer for formats of the same + * top-level type, but is not capable of + * rendering the format or any other format + * with the same mime type because the + * sub-type is not supported. + * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering + * the format, either because it does not support + * the format's top-level type, or because it's + * a specialized renderer for a different mime type. + * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats, + * but may suffer a brief discontinuity (~50-100ms) + * when adaptation occurs. + */ + final String mimeType = format.sampleMimeType; + if (!MimeTypes.isAudio(mimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + List<MediaCodecInfo> decoderInfos = null; + try { + final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT; + decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (decoderInfos == null || decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + final MediaCodecInfo info = decoderInfos.get(0); + /* + * Note : If the code can make it to this place, ExoPlayer assumes + * support for unknown sampleRate and channelCount when + * SDK version is less than 21, otherwise, further check is needed + * if there's no sampleRate/channelCount in format. + */ + final boolean decoderCapable = + (Build.VERSION.SDK_INT < 21) + || ((format.sampleRate == Format.NO_VALUE + || info.isAudioSampleRateSupportedV21(format.sampleRate)) + && (format.channelCount == Format.NO_VALUE + || info.isAudioChannelCountSupportedV21(format.channelCount))); + return RendererCapabilities.create( + decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES, + ADAPTIVE_NOT_SEAMLESS, + TUNNELING_NOT_SUPPORTED); + } + + @Override + protected final void createInputBuffer() { + // We're not able to estimate the size for audio from format. So we rely + // on the dynamic allocation mechanism provided in DecoderInputBuffer. + mInputBuffer = null; + } + + @Override + protected void resetRenderer() { + mInputBuffer = null; + mInitialized = false; + } + + @Override + protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) { + // Do nothing + } + + @Override + protected void handleFormatRead(final DecoderInputBuffer bufferForRead) + throws ExoPlaybackException { + onInputFormatChanged(mFormatHolder.format); + } + + @Override + protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) { + mInputStreamEnded = true; + mDemuxedInputSamples.offer(GeckoHLSSample.EOS); + } + + @Override + protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) { + final int size = bufferForRead.data.limit(); + final byte[] realData = new byte[size]; + bufferForRead.data.get(realData, 0, size); + final ByteBuffer buffer = ByteBuffer.wrap(realData); + mInputBuffer = bufferForRead.data; + mInputBuffer.clear(); + + final CryptoInfo cryptoInfo = + bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null; + final BufferInfo bufferInfo = new BufferInfo(); + // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags. + int flags = 0; + flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; + flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0; + bufferInfo.set(0, size, bufferForRead.timeUs, flags); + + assertTrue(mFormats.size() >= 0); + // We add a new format in the list once format changes, so the formatIndex + // should indicate to the last(latest) format. + final GeckoHLSSample sample = + GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1); + + mDemuxedInputSamples.offer(sample); + + if (BuildConfig.DEBUG_BUILD) { + Log.d( + LOGTAG, + "Demuxed sample PTS : " + + sample.info.presentationTimeUs + + ", duration :" + + sample.duration + + ", formatIndex(" + + sample.formatIndex + + "), queue size : " + + mDemuxedInputSamples.size()); + } + } + + @Override + protected boolean clearInputSamplesQueue() { + if (DEBUG) { + Log.d(LOGTAG, "clearInputSamplesQueue"); + } + mDemuxedInputSamples.clear(); + return true; + } + + @Override + protected void notifyPlayerInputFormatChanged(final Format newFormat) { + mPlayerEventDispatcher.onAudioInputFormatChanged(newFormat); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java new file mode 100644 index 0000000000..2193eaa602 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsPlayer.java @@ -0,0 +1,1110 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.content.Context; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.DefaultLoadControl; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlayer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.PlaybackParameters; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Timeline; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.MediaSourceEventListener; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroup; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.TrackGroupArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.source.hls.HlsMediaSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelection; +import org.mozilla.thirdparty.com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultAllocator; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import org.mozilla.thirdparty.com.google.android.exoplayer2.upstream.HttpDataSource; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.Util; + +@ReflectionTarget +public class GeckoHlsPlayer implements BaseHlsPlayer, ExoPlayer.EventListener { + private static final String LOGTAG = "GeckoHlsPlayer"; + private static final DefaultBandwidthMeter BANDWIDTH_METER = + new DefaultBandwidthMeter.Builder(null).build(); + private static final int MAX_TIMELINE_ITEM_LINES = 3; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + + private static final AtomicInteger sPlayerId = new AtomicInteger(0); + /* + * Because we treat GeckoHlsPlayer as a source data provider. + * It will be created and initialized with a URL by HLSResource in + * Gecko media pipleine (in cpp). Once HLSDemuxer is created later, we + * need to bridge this HLSResource to the created demuxer. And they share + * the same GeckoHlsPlayer. + * mPlayerId is a token used for Gecko media pipeline to obtain corresponding player. + */ + private final int mPlayerId; + // Accessed only in GeckoHlsPlayerThread. + private boolean mExoplayerSuspended = false; + + private static final int DEFAULT_MIN_BUFFER_MS = 5 * 1000; + private static final int DEFAULT_MAX_BUFFER_MS = 10 * 1000; + + private enum MediaDecoderPlayState { + PLAY_STATE_PREPARING, + PLAY_STATE_PAUSED, + PLAY_STATE_PLAYING + } + // Default value is PLAY_STATE_PREPARING and it will be set to PLAY_STATE_PLAYING + // once HTMLMediaElement calls PlayInternal(). + // Accessed only in GeckoHlsPlayerThread. + private MediaDecoderPlayState mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PREPARING; + + private Handler mMainHandler; + private HandlerThread mThread; + private ExoPlayer mPlayer; + private GeckoHlsRendererBase[] mRenderers; + private DefaultTrackSelector mTrackSelector; + private MediaSource mMediaSource; + private SourceEventListener mSourceEventListener; + private ComponentListener mComponentListener; + private ComponentEventDispatcher mComponentEventDispatcher; + + private volatile boolean mIsTimelineStatic = false; + private long mDurationUs; + + private GeckoHlsVideoRenderer mVRenderer = null; + private GeckoHlsAudioRenderer mARenderer = null; + + // Able to control if we only want V/A/V+A tracks from bitstream. + private class RendererController { + private final boolean mEnableV; + private final boolean mEnableA; + + RendererController(final boolean enableVideoRenderer, final boolean enableAudioRenderer) { + this.mEnableV = enableVideoRenderer; + this.mEnableA = enableAudioRenderer; + } + + boolean isVideoRendererEnabled() { + return mEnableV; + } + + boolean isAudioRendererEnabled() { + return mEnableA; + } + } + + private RendererController mRendererController = new RendererController(true, true); + + // Provide statistical information of tracks. + private class HlsMediaTracksInfo { + private int mNumVideoTracks = 0; + private int mNumAudioTracks = 0; + private boolean mVideoInfoUpdated = false; + private boolean mAudioInfoUpdated = false; + private boolean mVideoDataArrived = false; + private boolean mAudioDataArrived = false; + + HlsMediaTracksInfo() {} + + public void reset() { + mNumVideoTracks = 0; + mNumAudioTracks = 0; + mVideoInfoUpdated = false; + mAudioInfoUpdated = false; + mVideoDataArrived = false; + mAudioDataArrived = false; + } + + public void updateNumOfVideoTracks(final int numOfTracks) { + mNumVideoTracks = numOfTracks; + } + + public void updateNumOfAudioTracks(final int numOfTracks) { + mNumAudioTracks = numOfTracks; + } + + public boolean hasVideo() { + return mNumVideoTracks > 0; + } + + public boolean hasAudio() { + return mNumAudioTracks > 0; + } + + public int getNumOfVideoTracks() { + return mNumVideoTracks; + } + + public int getNumOfAudioTracks() { + return mNumAudioTracks; + } + + public void onVideoInfoUpdated() { + mVideoInfoUpdated = true; + } + + public void onAudioInfoUpdated() { + mAudioInfoUpdated = true; + } + + public void onDataArrived(final int trackType) { + if (trackType == C.TRACK_TYPE_VIDEO) { + mVideoDataArrived = true; + } else if (trackType == C.TRACK_TYPE_AUDIO) { + mAudioDataArrived = true; + } + } + + public boolean videoReady() { + return !hasVideo() || (mVideoInfoUpdated && mVideoDataArrived); + } + + public boolean audioReady() { + return !hasAudio() || (mAudioInfoUpdated && mAudioDataArrived); + } + } + + private HlsMediaTracksInfo mTracksInfo = new HlsMediaTracksInfo(); + + // Used only in GeckoHlsPlayerThread. + private boolean mIsPlayerInitDone = false; + private boolean mIsDemuxerInitDone = false; + private BaseHlsPlayer.DemuxerCallbacks mDemuxerCallbacks; + private BaseHlsPlayer.ResourceCallbacks mResourceCallbacks; + + private boolean mReleasing = false; // Used only in Gecko Main thread. + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + protected void checkInitDone() { + if (mIsDemuxerInitDone) { + return; + } + assertTrue(mDemuxerCallbacks != null); + + if (DEBUG) { + Log.d( + LOGTAG, + "[checkInitDone] VReady:" + + mTracksInfo.videoReady() + + ",AReady:" + + mTracksInfo.audioReady() + + ",hasV:" + + mTracksInfo.hasVideo() + + ",hasA:" + + mTracksInfo.hasAudio()); + } + if (mTracksInfo.videoReady() && mTracksInfo.audioReady()) { + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onInitialized(mTracksInfo.hasAudio(), mTracksInfo.hasVideo()); + } + mIsDemuxerInitDone = true; + } + } + + private final class SourceEventListener implements MediaSourceEventListener { + public void onLoadStarted( + final int windowIndex, + final MediaSource.MediaPeriodId mediaPeriodId, + final LoadEventInfo loadEventInfo, + final MediaLoadData mediaLoadData) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (mediaLoadData.dataType != C.DATA_TYPE_MEDIA) { + // Don't report non-media URLs. + return; + } + if (mResourceCallbacks == null || loadEventInfo.uri == null || mReleasing) { + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "on-load: url=" + loadEventInfo.uri); + } + mResourceCallbacks.onLoad(loadEventInfo.uri.toString()); + } + } + } + + public final class ComponentEventDispatcher { + // Called from GeckoHls{Audio,Video}Renderer/ExoPlayer internal playback thread + // or GeckoHlsPlayerThread. + public void onDataArrived(final int trackType) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onDataArrived(trackType)); + } + } + + // Called from GeckoHls{Audio,Video}Renderer internal playback thread. + public void onVideoInputFormatChanged(final Format format) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onVideoInputFormatChanged(format)); + } + } + + // Called from GeckoHls{Audio,Video}Renderer internal playback thread. + public void onAudioInputFormatChanged(final Format format) { + assertTrue(mComponentListener != null); + + if (mComponentListener != null) { + runOnPlayerThread(() -> mComponentListener.onAudioInputFormatChanged(format)); + } + } + } + + public final class ComponentListener { + + // General purpose implementation + // Called on GeckoHlsPlayerThread + public void onDataArrived(final int trackType) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB][onDataArrived] id " + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + + mTracksInfo.onDataArrived(trackType); + if (!mReleasing) { + mResourceCallbacks.onDataArrived(); + } + checkInitDone(); + } + } + + // Called on GeckoHlsPlayerThread + public void onVideoInputFormatChanged(final Format format) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB] onVideoInputFormatChanged [" + format + "]"); + Log.d( + LOGTAG, + "[CB] SampleMIMEType [" + + format.sampleMimeType + + "], ContainerMIMEType [" + + format.containerMimeType + + "], id : " + + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + mTracksInfo.onVideoInfoUpdated(); + checkInitDone(); + } + } + + // Called on GeckoHlsPlayerThread + public void onAudioInputFormatChanged(final Format format) { + assertTrue(isPlayerThread()); + + synchronized (GeckoHlsPlayer.this) { + if (DEBUG) { + Log.d(LOGTAG, "[CB] onAudioInputFormatChanged [" + format + "], mPlayerId :" + mPlayerId); + } + if (!mIsPlayerInitDone) { + return; + } + mTracksInfo.onAudioInfoUpdated(); + checkInitDone(); + } + } + } + + private HlsMediaSource.Factory buildDataSourceFactory( + final Context ctx, final DefaultBandwidthMeter bandwidthMeter) { + return new HlsMediaSource.Factory( + new DefaultDataSourceFactory( + ctx, bandwidthMeter, buildHttpDataSourceFactory(bandwidthMeter))); + } + + private HttpDataSource.Factory buildHttpDataSourceFactory( + final DefaultBandwidthMeter bandwidthMeter) { + return new DefaultHttpDataSourceFactory( + BuildConfig.USER_AGENT_GECKOVIEW_MOBILE, + bandwidthMeter /* listener */, + DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, + DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, + true /* allowCrossProtocolRedirects */); + } + + private long getDuration() { + return awaitPlayerThread( + () -> { + long duration = 0L; + // Value returned by getDuration() is in milliseconds. + if (mPlayer != null && !isLiveStream()) { + duration = Math.max(0L, mPlayer.getDuration() * 1000L); + } + if (DEBUG) { + Log.d(LOGTAG, "getDuration : " + duration + "(Us)"); + } + return duration; + }); + } + + // To make sure that each player has a unique id, GeckoHlsPlayer should be + // created only from synchronized APIs in GeckoPlayerFactory. + public GeckoHlsPlayer() { + mPlayerId = sPlayerId.incrementAndGet(); + if (DEBUG) { + Log.d(LOGTAG, " construct player with id(" + mPlayerId + ")"); + } + } + + // Should be only called by GeckoPlayerFactory and GeckoHLSResourceWrapper. + // The mPlayerId is used to make sure that the same GeckoHlsPlayer is used by + // corresponding HLSResource and HLSDemuxer for each media playback. + // Called on Gecko's main thread + @Override + public int getId() { + return mPlayerId; + } + + // Called on Gecko's main thread + @Override + public synchronized void addDemuxerWrapperCallbackListener( + final BaseHlsPlayer.DemuxerCallbacks callback) { + if (DEBUG) { + Log.d(LOGTAG, " addDemuxerWrapperCallbackListener ..."); + } + mDemuxerCallbacks = callback; + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onLoadingChanged(final boolean isLoading) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "loading [" + isLoading + "]"); + } + if (!isLoading) { + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + suspendExoplayer(); + } + // To update buffered position. + mComponentEventDispatcher.onDataArrived(C.TRACK_TYPE_DEFAULT); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onPlayerStateChanged(final boolean playWhenReady, final int state) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "state [" + playWhenReady + ", " + getStateString(state) + "]"); + } + if (state == ExoPlayer.STATE_READY + && !mExoplayerSuspended + && mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + resumeExoplayer(); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public void onPositionDiscontinuity(final int reason) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "positionDiscontinuity: reason=" + reason); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public void onPlaybackParametersChanged(final PlaybackParameters playbackParameters) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d( + LOGTAG, + "playbackParameters " + + String.format( + "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch)); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onPlayerError(final ExoPlaybackException e) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.e(LOGTAG, "playerFailed", e); + } + mIsPlayerInitDone = false; + if (mReleasing) { + return; + } + if (mResourceCallbacks != null) { + mResourceCallbacks.onError(ResourceError.PLAYER.code()); + } + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onError(DemuxerError.PLAYER.code()); + } + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onTracksChanged( + final TrackGroupArray ignored, final TrackSelectionArray trackSelections) { + assertTrue(isPlayerThread()); + + if (DEBUG) { + Log.d(LOGTAG, "onTracksChanged : TGA[" + ignored + "], TSA[" + trackSelections + "]"); + + final MappedTrackInfo mappedTrackInfo = mTrackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + Log.d(LOGTAG, "Tracks []"); + return; + } + Log.d(LOGTAG, "Tracks ["); + // Log tracks associated to renderers. + for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) { + final TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + final TrackSelection trackSelection = trackSelections.get(rendererIndex); + if (rendererTrackGroups.length > 0) { + Log.d(LOGTAG, " Renderer:" + rendererIndex + " ["); + for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { + final TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); + final String adaptiveSupport = + getAdaptiveSupportString( + trackGroup.length, + mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false)); + Log.d( + LOGTAG, + " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + final String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); + final String formatSupport = + getFormatSupportString( + mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); + Log.d( + LOGTAG, + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, " ]"); + } + } + // Log tracks not associated with a renderer. + final TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups(); + if (unassociatedTrackGroups.length > 0) { + Log.d(LOGTAG, " Renderer:None ["); + for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { + Log.d(LOGTAG, " Group:" + groupIndex + " ["); + final TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + final String status = getTrackStatusString(false); + final String formatSupport = + getFormatSupportString(RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); + Log.d( + LOGTAG, + " " + + status + + " Track:" + + trackIndex + + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + + formatSupport); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, " ]"); + } + Log.d(LOGTAG, "]"); + } + mTracksInfo.reset(); + int numVideoTracks = 0; + int numAudioTracks = 0; + for (int j = 0; j < ignored.length; j++) { + final TrackGroup tg = ignored.get(j); + for (int i = 0; i < tg.length; i++) { + final Format fmt = tg.getFormat(i); + if (fmt.sampleMimeType != null) { + if (mRendererController.isVideoRendererEnabled() + && fmt.sampleMimeType.startsWith(new String("video"))) { + numVideoTracks++; + } else if (mRendererController.isAudioRendererEnabled() + && fmt.sampleMimeType.startsWith(new String("audio"))) { + numAudioTracks++; + } + } + } + } + mTracksInfo.updateNumOfVideoTracks(numVideoTracks); + mTracksInfo.updateNumOfAudioTracks(numAudioTracks); + } + + // Called on GeckoHlsPlayerThread from ExoPlayer + @Override + public synchronized void onTimelineChanged(final Timeline timeline, final int reason) { + assertTrue(isPlayerThread()); + + // For now, we use the interface ExoPlayer.getDuration() for gecko, + // so here we create local variable 'window' & 'peroid' to obtain + // the dynamic duration. + // See. + // http://google.github.io/ExoPlayer/doc/reference/com/google/android/exoplayer2/Timeline.html + // for further information. + final Timeline.Window window = new Timeline.Window(); + mIsTimelineStatic = + !timeline.isEmpty() && !timeline.getWindow(timeline.getWindowCount() - 1, window).isDynamic; + + final int periodCount = timeline.getPeriodCount(); + final int windowCount = timeline.getWindowCount(); + if (DEBUG) { + Log.d(LOGTAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount); + } + final Timeline.Period period = new Timeline.Period(); + for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getPeriod(i, period); + if (mDurationUs < period.getDurationUs()) { + mDurationUs = period.getDurationUs(); + } + } + for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getWindow(i, window); + if (mDurationUs < window.getDurationUs()) { + mDurationUs = window.getDurationUs(); + } + } + // TODO : Need to check if the duration from play.getDuration is different + // with the one calculated from multi-timelines/windows. + if (DEBUG) { + Log.d( + LOGTAG, + "Media duration (from Timeline) = " + + mDurationUs + + "(us)" + + " player.getDuration() = " + + mPlayer.getDuration() + + "(ms)"); + } + } + + private static String getStateString(final int state) { + switch (state) { + case ExoPlayer.STATE_BUFFERING: + return "B"; + case ExoPlayer.STATE_ENDED: + return "E"; + case ExoPlayer.STATE_IDLE: + return "I"; + case ExoPlayer.STATE_READY: + return "R"; + default: + return "?"; + } + } + + private static String getFormatSupportString(final int formatSupport) { + switch (formatSupport) { + case RendererCapabilities.FORMAT_HANDLED: + return "YES"; + case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: + return "NO_EXCEEDS_CAPABILITIES"; + case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: + return "NO_UNSUPPORTED_TYPE"; + case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: + return "NO"; + default: + return "?"; + } + } + + private static String getAdaptiveSupportString(final int trackCount, final int adaptiveSupport) { + if (trackCount < 2) { + return "N/A"; + } + switch (adaptiveSupport) { + case RendererCapabilities.ADAPTIVE_SEAMLESS: + return "YES"; + case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS: + return "YES_NOT_SEAMLESS"; + case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED: + return "NO"; + default: + return "?"; + } + } + + private static String getTrackStatusString( + final TrackSelection selection, final TrackGroup group, final int trackIndex) { + return getTrackStatusString( + selection != null + && selection.getTrackGroup() == group + && selection.indexOf(trackIndex) != C.INDEX_UNSET); + } + + private static String getTrackStatusString(final boolean enabled) { + return enabled ? "[X]" : "[ ]"; + } + + // Called on GeckoHlsPlayerThread + private void createExoPlayer(final String url) { + assertTrue(isPlayerThread()); + + final Context ctx = GeckoAppShell.getApplicationContext(); + mComponentListener = new ComponentListener(); + mComponentEventDispatcher = new ComponentEventDispatcher(); + mDurationUs = 0; + + // Prepare trackSelector + final TrackSelection.Factory videoTrackSelectionFactory = + new AdaptiveTrackSelection.Factory(BANDWIDTH_METER); + mTrackSelector = new DefaultTrackSelector(videoTrackSelectionFactory); + + // Prepare customized renderer + mRenderers = new GeckoHlsRendererBase[2]; + mVRenderer = new GeckoHlsVideoRenderer(mComponentEventDispatcher); + mARenderer = new GeckoHlsAudioRenderer(mComponentEventDispatcher); + mRenderers[0] = mVRenderer; + mRenderers[1] = mARenderer; + + final DefaultLoadControl dlc = + new DefaultLoadControl.Builder() + .setAllocator(new DefaultAllocator(true, C.DEFAULT_BUFFER_SEGMENT_SIZE)) + .setBufferDurationsMs( + DEFAULT_MIN_BUFFER_MS, + DEFAULT_MAX_BUFFER_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS) + .createDefaultLoadControl(); + // Create ExoPlayer instance with specific components. + mPlayer = + new ExoPlayer.Builder(ctx, mRenderers) + .setTrackSelector(mTrackSelector) + .setLoadControl(dlc) + .build(); + mPlayer.addListener(this); + + final Uri uri = Uri.parse(url); + mMediaSource = buildDataSourceFactory(ctx, BANDWIDTH_METER).createMediaSource(uri); + mSourceEventListener = new SourceEventListener(); + mMediaSource.addEventListener(mMainHandler, mSourceEventListener); + if (DEBUG) { + Log.d( + LOGTAG, + "Uri is " + uri + ", ContentType is " + Util.inferContentType(uri.getLastPathSegment())); + } + mPlayer.setPlayWhenReady(false); + mPlayer.prepare(mMediaSource); + mIsPlayerInitDone = true; + } + // ======================================================================= + // API for GeckoHLSResourceWrapper + // ======================================================================= + // Called on Gecko Main Thread + @Override + public synchronized void init(final String url, final BaseHlsPlayer.ResourceCallbacks callback) { + if (DEBUG) { + Log.d(LOGTAG, " init"); + } + assertTrue(callback != null); + assertTrue(!mIsPlayerInitDone); + + mThread = new HandlerThread("GeckoHlsPlayerThread"); + mThread.start(); + mMainHandler = new Handler(mThread.getLooper()); + + mMainHandler.post( + () -> { + mResourceCallbacks = callback; + createExoPlayer(url); + }); + } + + // Called on MDSM's TaskQueue + @Override + public boolean isLiveStream() { + return !mIsTimelineStatic; + } + // ======================================================================= + // API for GeckoHLSDemuxerWrapper + // ======================================================================= + // Called on HLSDemuxer's TaskQueue + @Override + public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getSamples( + final TrackType trackType, final int number) { + if (trackType == TrackType.VIDEO) { + return mVRenderer != null + ? mVRenderer.getQueuedSamples(number) + : new ConcurrentLinkedQueue<GeckoHLSSample>(); + } else if (trackType == TrackType.AUDIO) { + return mARenderer != null + ? mARenderer.getQueuedSamples(number) + : new ConcurrentLinkedQueue<GeckoHLSSample>(); + } else { + return new ConcurrentLinkedQueue<GeckoHLSSample>(); + } + } + + // Called on MFR's TaskQueue + @Override + public long getBufferedPosition() { + return awaitPlayerThread( + () -> { + // Value returned by getBufferedPosition() is in milliseconds. + final long bufferedPos = + mPlayer == null ? 0L : Math.max(0L, mPlayer.getBufferedPosition() * 1000L); + if (DEBUG) { + Log.d(LOGTAG, "getBufferedPosition : " + bufferedPos + "(Us)"); + } + return bufferedPos; + }); + } + + // Called on MFR's TaskQueue + @Override + public synchronized int getNumberOfTracks(final TrackType trackType) { + if (DEBUG) { + Log.d(LOGTAG, "getNumberOfTracks : type " + trackType); + } + if (trackType == TrackType.VIDEO) { + return mTracksInfo.getNumOfVideoTracks(); + } else if (trackType == TrackType.AUDIO) { + return mTracksInfo.getNumOfAudioTracks(); + } + return 0; + } + + // Called on MFR's TaskQueue + @Override + public GeckoVideoInfo getVideoInfo(final int index) { + final Format fmt; + synchronized (this) { + if (DEBUG) { + Log.d(LOGTAG, "getVideoInfo"); + } + if (mVRenderer == null) { + Log.e(LOGTAG, "no render to get video info from. Index : " + index); + return null; + } + if (!mTracksInfo.hasVideo()) { + return null; + } + fmt = mVRenderer.getFormat(index); + if (fmt == null) { + return null; + } + } + final GeckoVideoInfo vInfo = + new GeckoVideoInfo( + fmt.width, + fmt.height, + fmt.width, + fmt.height, + fmt.rotationDegrees, + fmt.stereoMode, + getDuration(), + fmt.sampleMimeType, + null, + null); + return vInfo; + } + + // Called on MFR's TaskQueue + @Override + public GeckoAudioInfo getAudioInfo(final int index) { + final Format fmt; + synchronized (this) { + if (DEBUG) { + Log.d(LOGTAG, "getAudioInfo"); + } + if (mARenderer == null) { + Log.e(LOGTAG, "no render to get audio info from. Index : " + index); + return null; + } + if (!mTracksInfo.hasAudio()) { + return null; + } + fmt = mARenderer.getFormat(index); + if (fmt == null) { + return null; + } + } + /* According to https://github.com/google/ExoPlayer/blob + * /d979469659861f7fe1d39d153b90bdff1ab479cc/library/core/src/main + * /java/com/google/android/exoplayer2/audio/MediaCodecAudioRenderer.java#L221-L224, + * if the input audio format is not raw, exoplayer would assure that + * the sample's pcm encoding bitdepth is 16. + * For HLS content, it should always be 16. + */ + assertTrue(!MimeTypes.AUDIO_RAW.equals(fmt.sampleMimeType)); + // For HLS content, csd-0 is enough. + final byte[] csd = fmt.initializationData.isEmpty() ? null : fmt.initializationData.get(0); + final GeckoAudioInfo aInfo = + new GeckoAudioInfo( + fmt.sampleRate, fmt.channelCount, 16, 0, getDuration(), fmt.sampleMimeType, csd); + return aInfo; + } + + // Called on HLSDemuxer's TaskQueue + @Override + public boolean seek(final long positionUs) { + synchronized (this) { + if (mPlayer == null) { + Log.d(LOGTAG, "Seek operation won't be performed as no player exists!"); + return false; + } + } + return awaitPlayerThread( + () -> { + // Need to temporarily resume Exoplayer to download the chunks for getting the demuxed + // keyframe sample when HTMLMediaElement is paused. Suspend Exoplayer when collecting + // enough + // samples in onLoadingChanged. + if (mExoplayerSuspended) { + resumeExoplayer(); + } + // positionUs : microseconds. + // NOTE : 1) It's not possible to seek media by tracktype via ExoPlayer Interface. + // 2) positionUs is samples PTS from MFR, we need to re-adjust it + // for ExoPlayer by subtracting sample start time. + // 3) Time unit for ExoPlayer.seek() is milliseconds. + try { + // TODO : Gather Timeline Period / Window information to develop + // complete timeline, and seekTime should be inside the duration. + Long startTime = Long.MAX_VALUE; + for (final GeckoHlsRendererBase r : mRenderers) { + if (r == mVRenderer + && mRendererController.isVideoRendererEnabled() + && mTracksInfo.hasVideo() + || r == mARenderer + && mRendererController.isAudioRendererEnabled() + && mTracksInfo.hasAudio()) { + // Find the min value of the start time + startTime = Math.min(startTime, r.getFirstSamplePTS()); + } + } + if (DEBUG) { + Log.d( + LOGTAG, + "seeking : " + + positionUs / 1000 + + " (ms); startTime : " + + startTime / 1000 + + " (ms)"); + } + assertTrue(startTime != Long.MAX_VALUE && startTime != Long.MIN_VALUE); + mPlayer.seekTo(positionUs / 1000 - startTime / 1000); + } catch (final Exception e) { + if (mReleasing) { + return false; + } + if (mDemuxerCallbacks != null) { + mDemuxerCallbacks.onError(DemuxerError.UNKNOWN.code()); + } + return false; + } + return true; + }); + } + + // Called on HLSDemuxer's TaskQueue + @Override + public synchronized long getNextKeyFrameTime() { + final long nextKeyFrameTime = + mVRenderer != null ? mVRenderer.getNextKeyFrameTime() : Long.MAX_VALUE; + return nextKeyFrameTime; + } + + // Called on Gecko's main thread. + @Override + public synchronized void suspend() { + runOnPlayerThread( + () -> { + if (mExoplayerSuspended) { + return; + } + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + if (DEBUG) { + Log.d(LOGTAG, "suspend player id : " + mPlayerId); + } + suspendExoplayer(); + } + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void resume() { + runOnPlayerThread( + () -> { + if (!mExoplayerSuspended) { + return; + } + if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + if (DEBUG) { + Log.d(LOGTAG, "resume player id : " + mPlayerId); + } + resumeExoplayer(); + } + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void play() { + runOnPlayerThread( + () -> { + if (mMediaDecoderPlayState == MediaDecoderPlayState.PLAY_STATE_PLAYING) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "MediaDecoder played."); + } + mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PLAYING; + resumeExoplayer(); + }); + } + + // Called on Gecko's main thread. + @Override + public synchronized void pause() { + runOnPlayerThread( + () -> { + if (mMediaDecoderPlayState != MediaDecoderPlayState.PLAY_STATE_PLAYING) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "MediaDecoder paused."); + } + mMediaDecoderPlayState = MediaDecoderPlayState.PLAY_STATE_PAUSED; + suspendExoplayer(); + }); + } + + private void suspendExoplayer() { + assertTrue(isPlayerThread()); + + if (mPlayer == null) { + return; + } + mExoplayerSuspended = true; + if (DEBUG) { + Log.d(LOGTAG, "suspend Exoplayer"); + } + mPlayer.setPlayWhenReady(false); + } + + private void resumeExoplayer() { + assertTrue(isPlayerThread()); + + if (mPlayer == null) { + return; + } + mExoplayerSuspended = false; + if (DEBUG) { + Log.d(LOGTAG, "resume Exoplayer"); + } + mPlayer.setPlayWhenReady(true); + } + + // Called on Gecko's main thread, when HLSDemuxer or HLSResource destructs. + @Override + public void release() { + if (DEBUG) { + Log.d(LOGTAG, "releasing ... id : " + mPlayerId); + } + + synchronized (this) { + if (mReleasing) { + return; + } else { + mReleasing = true; + } + } + + runOnPlayerThread( + () -> { + if (mPlayer != null) { + mPlayer.removeListener(this); + mPlayer.stop(); + mPlayer.release(); + mVRenderer = null; + mARenderer = null; + mPlayer = null; + } + if (mThread != null) { + mThread.quit(); + mThread = null; + } + mDemuxerCallbacks = null; + mResourceCallbacks = null; + mIsPlayerInitDone = false; + mIsDemuxerInitDone = false; + }); + } + + private void runOnPlayerThread(final Runnable task) { + assertTrue(mMainHandler != null); + if (isPlayerThread()) { + task.run(); + } else { + mMainHandler.post(task); + } + } + + private boolean isPlayerThread() { + return Thread.currentThread() == mMainHandler.getLooper().getThread(); + } + + private <T> T awaitPlayerThread(final Callable<T> task) { + assertTrue(!isPlayerThread()); + + try { + final FutureTask<T> wait = new FutureTask<T>(task); + mMainHandler.post(wait); + return wait.get(); + } catch (final Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java new file mode 100644 index 0000000000..ecb7b93d61 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsRendererBase.java @@ -0,0 +1,340 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.BaseRenderer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.FormatHolder; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; + +public abstract class GeckoHlsRendererBase extends BaseRenderer { + protected static final int QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD = 1000000; // 1sec + protected final FormatHolder mFormatHolder = new FormatHolder(); + /* + * DEBUG/LOGTAG will be set in the 2 subclass GeckoHlsAudioRenderer and + * GeckoHlsVideoRenderer, and we still wants to log message in the base class + * GeckoHlsRendererBase, so neither 'static' nor 'final' are applied to them. + */ + protected boolean DEBUG; + protected String LOGTAG; + // Notify GeckoHlsPlayer about renderer's status, i.e. data has arrived. + protected GeckoHlsPlayer.ComponentEventDispatcher mPlayerEventDispatcher; + + protected ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedInputSamples = + new ConcurrentLinkedQueue<>(); + + protected ByteBuffer mInputBuffer = null; + protected ArrayList<Format> mFormats = new ArrayList<Format>(); + protected boolean mInitialized = false; + protected boolean mWaitingForData = true; + protected boolean mInputStreamEnded = false; + protected long mFirstSampleStartTime = Long.MIN_VALUE; + + protected abstract void createInputBuffer() throws ExoPlaybackException; + + protected abstract void handleReconfiguration(DecoderInputBuffer bufferForRead); + + protected abstract void handleFormatRead(DecoderInputBuffer bufferForRead) + throws ExoPlaybackException; + + protected abstract void handleEndOfStream(DecoderInputBuffer bufferForRead); + + protected abstract void handleSamplePreparation(DecoderInputBuffer bufferForRead); + + protected abstract void resetRenderer(); + + protected abstract boolean clearInputSamplesQueue(); + + protected abstract void notifyPlayerInputFormatChanged(Format newFormat); + + private DecoderInputBuffer mBufferForRead = + new DecoderInputBuffer(DecoderInputBuffer.BUFFER_REPLACEMENT_MODE_NORMAL); + private final DecoderInputBuffer mFlagsOnlyBuffer = DecoderInputBuffer.newFlagsOnlyInstance(); + + protected void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public GeckoHlsRendererBase( + final int trackType, final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) { + super(trackType); + mPlayerEventDispatcher = eventDispatcher; + } + + private boolean isQueuedEnoughData() { + if (mDemuxedInputSamples.isEmpty()) { + return false; + } + + final Iterator<GeckoHLSSample> iter = mDemuxedInputSamples.iterator(); + long firstPTS = 0; + if (iter.hasNext()) { + final GeckoHLSSample sample = iter.next(); + firstPTS = sample.info.presentationTimeUs; + } + long lastPTS = firstPTS; + while (iter.hasNext()) { + final GeckoHLSSample sample = iter.next(); + lastPTS = sample.info.presentationTimeUs; + } + return Math.abs(lastPTS - firstPTS) > QUEUED_INPUT_SAMPLE_DURATION_THRESHOLD; + } + + public Format getFormat(final int index) { + assertTrue(index >= 0); + final Format fmt = index < mFormats.size() ? mFormats.get(index) : null; + if (DEBUG) { + Log.d(LOGTAG, "getFormat : index = " + index + ", format : " + fmt); + } + return fmt; + } + + public synchronized long getFirstSamplePTS() { + return mFirstSampleStartTime; + } + + public synchronized ConcurrentLinkedQueue<GeckoHLSSample> getQueuedSamples(final int number) { + final ConcurrentLinkedQueue<GeckoHLSSample> samples = + new ConcurrentLinkedQueue<GeckoHLSSample>(); + + GeckoHLSSample sample = null; + final int queuedSize = mDemuxedInputSamples.size(); + for (int i = 0; i < queuedSize; i++) { + if (i >= number) { + break; + } + sample = mDemuxedInputSamples.poll(); + samples.offer(sample); + } + + sample = samples.isEmpty() ? null : samples.peek(); + if (sample == null) { + if (DEBUG) { + Log.d(LOGTAG, "getQueuedSamples isEmpty, mWaitingForData = true !"); + } + mWaitingForData = true; + } else if (mFirstSampleStartTime == Long.MIN_VALUE) { + mFirstSampleStartTime = sample.info.presentationTimeUs; + if (DEBUG) { + Log.d(LOGTAG, "mFirstSampleStartTime = " + mFirstSampleStartTime); + } + } + return samples; + } + + protected void handleDrmInitChanged(final Format oldFormat, final Format newFormat) { + final Object oldDrmInit = oldFormat == null ? null : oldFormat.drmInitData; + final Object newDrnInit = newFormat.drmInitData; + + // TODO: Notify MFR if the content is encrypted or not. + if (newDrnInit != oldDrmInit) { + if (newDrnInit != null) { + } else { + } + } + } + + protected boolean canReconfigure(final Format oldFormat, final Format newFormat) { + // Referring to ExoPlayer's MediaCodecBaseRenderer, the default is set + // to false. Only override it in video renderer subclass. + return false; + } + + protected void prepareReconfiguration() { + // Referring to ExoPlayer's MediaCodec related renderers, only video + // renderer handles this. + } + + protected void updateCSDInfo(final Format format) { + // do nothing. + } + + protected void onInputFormatChanged(final Format newFormat) throws ExoPlaybackException { + Format oldFormat; + try { + oldFormat = mFormats.get(mFormats.size() - 1); + } catch (final IndexOutOfBoundsException e) { + oldFormat = null; + } + if (DEBUG) { + Log.d(LOGTAG, "[onInputFormatChanged] old : " + oldFormat + " => new : " + newFormat); + } + mFormats.add(newFormat); + handleDrmInitChanged(oldFormat, newFormat); + + if (mInitialized && canReconfigure(oldFormat, newFormat)) { + prepareReconfiguration(); + } else { + resetRenderer(); + maybeInitRenderer(); + } + + updateCSDInfo(newFormat); + notifyPlayerInputFormatChanged(newFormat); + } + + protected void maybeInitRenderer() throws ExoPlaybackException { + if (mInitialized || mFormats.size() == 0) { + return; + } + if (DEBUG) { + Log.d(LOGTAG, "Initializing ... "); + } + try { + createInputBuffer(); + mInitialized = true; + } catch (final OutOfMemoryError e) { + throw ExoPlaybackException.createForRenderer( + new RuntimeException(e), + getIndex(), + mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1), + RendererCapabilities.FORMAT_HANDLED); + } + } + + /* + * The place we get demuxed data from HlsMediaSource(ExoPlayer). + * The data will then be converted to GeckoHLSSample and deliver to + * GeckoHlsDemuxerWrapper for further use. + * If the return value is ture, that means a GeckoHLSSample is queued + * successfully. We can try to feed more samples into queue. + * If the return value is false, that means we might encounter following + * situation 1) not initialized 2) input stream is ended 3) queue is full. + * 4) format changed. 5) exception happened. + */ + protected synchronized boolean feedInputBuffersQueue() throws ExoPlaybackException { + if (!mInitialized || mInputStreamEnded || isQueuedEnoughData()) { + // Need to reinitialize the renderer or the input stream has ended + // or we just reached the maximum queue size. + return false; + } + + mBufferForRead.data = mInputBuffer; + if (mBufferForRead.data != null) { + mBufferForRead.clear(); + } + + handleReconfiguration(mBufferForRead); + + // Read data from HlsMediaSource + int result = C.RESULT_NOTHING_READ; + try { + result = readSource(mFormatHolder, mBufferForRead, false); + } catch (final Exception e) { + Log.e(LOGTAG, "[feedInput] Exception when readSource :", e); + return false; + } + + if (result == C.RESULT_NOTHING_READ) { + return false; + } + + if (result == C.RESULT_FORMAT_READ) { + handleFormatRead(mBufferForRead); + return true; + } + + // We've read a buffer. + if (mBufferForRead.isEndOfStream()) { + if (DEBUG) { + Log.d(LOGTAG, "Now we're at the End Of Stream."); + } + handleEndOfStream(mBufferForRead); + return false; + } + + mBufferForRead.flip(); + + handleSamplePreparation(mBufferForRead); + + maybeNotifyDataArrived(); + return true; + } + + private void maybeNotifyDataArrived() { + if (mWaitingForData && isQueuedEnoughData()) { + if (DEBUG) { + Log.d(LOGTAG, "onDataArrived"); + } + mPlayerEventDispatcher.onDataArrived(getTrackType()); + mWaitingForData = false; + } + } + + private void readFormat() throws ExoPlaybackException { + mFlagsOnlyBuffer.clear(); + final int result = readSource(mFormatHolder, mFlagsOnlyBuffer, true); + if (result == C.RESULT_FORMAT_READ) { + onInputFormatChanged(mFormatHolder.format); + } + } + + @Override + protected void onEnabled(final boolean joining) { + // Do nothing. + } + + @Override + protected void onDisabled() { + mFormats.clear(); + resetRenderer(); + } + + @Override + public boolean isReady() { + return mFormats.size() != 0; + } + + @Override + public boolean isEnded() { + return mInputStreamEnded; + } + + @Override + protected synchronized void onPositionReset(final long positionUs, final boolean joining) { + if (DEBUG) { + Log.d(LOGTAG, "onPositionReset : positionUs = " + positionUs); + } + mInputStreamEnded = false; + if (mInitialized) { + clearInputSamplesQueue(); + } + } + + /* + * This is called by ExoPlayerImplInternal.java. + * ExoPlayer checks the status of renderer, i.e. isReady() / isEnded(), and + * calls renderer.render by passing its wall clock time. + */ + @Override + public void render(final long positionUs, final long elapsedRealtimeUs) + throws ExoPlaybackException { + if (BuildConfig.DEBUG_BUILD) { + Log.d(LOGTAG, "positionUs = " + positionUs + ", mInputStreamEnded = " + mInputStreamEnded); + } + if (mInputStreamEnded) { + return; + } + if (mFormats.size() == 0) { + readFormat(); + } + + maybeInitRenderer(); + while (feedInputBuffersQueue()) { + // Do nothing + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java new file mode 100644 index 0000000000..f2917ccbcc --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoHlsVideoRenderer.java @@ -0,0 +1,518 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Build; +import android.util.Log; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.mozilla.geckoview.BuildConfig; +import org.mozilla.thirdparty.com.google.android.exoplayer2.C; +import org.mozilla.thirdparty.com.google.android.exoplayer2.ExoPlaybackException; +import org.mozilla.thirdparty.com.google.android.exoplayer2.Format; +import org.mozilla.thirdparty.com.google.android.exoplayer2.RendererCapabilities; +import org.mozilla.thirdparty.com.google.android.exoplayer2.decoder.DecoderInputBuffer; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecInfo; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import org.mozilla.thirdparty.com.google.android.exoplayer2.mediacodec.MediaCodecUtil; +import org.mozilla.thirdparty.com.google.android.exoplayer2.util.MimeTypes; + +public class GeckoHlsVideoRenderer extends GeckoHlsRendererBase { + /* + * By configuring these states, initialization data is provided for + * ExoPlayer's HlsMediaSource to parse HLS bitstream and then provide samples + * starting with an Access Unit Delimiter including SPS/PPS for TS, + * and provide samples starting with an AUD without SPS/PPS for FMP4. + */ + private enum RECONFIGURATION_STATE { + NONE, + WRITE_PENDING, + QUEUE_PENDING + } + + private boolean mRendererReconfigured; + private RECONFIGURATION_STATE mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + + // A list of the formats which may be included in the bitstream. + private Format[] mStreamFormats; + // The max width/height/inputBufferSize for specific codec format. + private CodecMaxValues mCodecMaxValues; + // A temporary queue for samples whose duration is not calculated yet. + private ConcurrentLinkedQueue<GeckoHLSSample> mDemuxedNoDurationSamples = + new ConcurrentLinkedQueue<>(); + + // Contain CSD-0(SPS)/CSD-1(PPS) information (in AnnexB format) for + // prepending each keyframe. When video format changes, this information + // changes accordingly. + private byte[] mCSDInfo = null; + + public GeckoHlsVideoRenderer(final GeckoHlsPlayer.ComponentEventDispatcher eventDispatcher) { + super(C.TRACK_TYPE_VIDEO, eventDispatcher); + assertTrue(Build.VERSION.SDK_INT >= 16); + LOGTAG = getClass().getSimpleName(); + DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + } + + @Override + public final int supportsMixedMimeTypeAdaptation() { + return ADAPTIVE_NOT_SEAMLESS; + } + + @Override + public final int supportsFormat(final Format format) { + /* + * FORMAT_EXCEEDS_CAPABILITIES : The Renderer is capable of rendering + * formats with the same mime type, but + * the properties of the format exceed + * the renderer's capability. + * FORMAT_UNSUPPORTED_SUBTYPE : The Renderer is a general purpose + * renderer for formats of the same + * top-level type, but is not capable of + * rendering the format or any other format + * with the same mime type because the + * sub-type is not supported. + * FORMAT_UNSUPPORTED_TYPE : The Renderer is not capable of rendering + * the format, either because it does not support + * the format's top-level type, or because it's + * a specialized renderer for a different mime type. + * ADAPTIVE_NOT_SEAMLESS : The Renderer can adapt between formats, + * but may suffer a brief discontinuity (~50-100ms) + * when adaptation occurs. + * ADAPTIVE_SEAMLESS : The Renderer can seamlessly adapt between formats. + */ + final String mimeType = format.sampleMimeType; + if (!MimeTypes.isVideo(mimeType)) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_TYPE); + } + + List<MediaCodecInfo> decoderInfos = null; + try { + final MediaCodecSelector mediaCodecSelector = MediaCodecSelector.DEFAULT; + decoderInfos = mediaCodecSelector.getDecoderInfos(mimeType, false, false); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (decoderInfos == null || decoderInfos.isEmpty()) { + return RendererCapabilities.create(FORMAT_UNSUPPORTED_SUBTYPE); + } + + boolean decoderCapable = false; + MediaCodecInfo info = null; + for (final MediaCodecInfo i : decoderInfos) { + if (i.isCodecSupported(format)) { + decoderCapable = true; + info = i; + } + } + if (decoderCapable && format.width > 0 && format.height > 0) { + if (Build.VERSION.SDK_INT < 21) { + try { + decoderCapable = + format.width * format.height <= MediaCodecUtil.maxH264DecodableFrameSize(); + } catch (final MediaCodecUtil.DecoderQueryException e) { + Log.e(LOGTAG, e.getMessage()); + } + if (!decoderCapable) { + if (DEBUG) { + Log.d(LOGTAG, "Check [legacyFrameSize, " + format.width + "x" + format.height + "]"); + } + } + } else { + decoderCapable = + info.isVideoSizeAndRateSupportedV21(format.width, format.height, format.frameRate); + } + } + + return RendererCapabilities.create( + decoderCapable ? FORMAT_HANDLED : FORMAT_EXCEEDS_CAPABILITIES, + info != null && info.adaptive ? ADAPTIVE_SEAMLESS : ADAPTIVE_NOT_SEAMLESS, + TUNNELING_NOT_SUPPORTED); + } + + @Override + protected final void createInputBuffer() throws ExoPlaybackException { + assertTrue(mFormats.size() > 0); + // Calculate maximum size which might be used for target format. + final Format currentFormat = mFormats.get(mFormats.size() - 1); + mCodecMaxValues = getCodecMaxValues(currentFormat, mStreamFormats); + // Create a buffer with maximal size for reading source. + // Note : Though we are able to dynamically enlarge buffer size by + // creating DecoderInputBuffer with specific BufferReplacementMode, we + // still allocate a calculated max size buffer for it at first to reduce + // runtime overhead. + try { + mInputBuffer = ByteBuffer.wrap(new byte[mCodecMaxValues.inputSize]); + } catch (final OutOfMemoryError e) { + Log.e(LOGTAG, "cannot allocate input buffer of size " + mCodecMaxValues.inputSize, e); + throw ExoPlaybackException.createForRenderer( + new Exception(e), + getIndex(), + mFormats.isEmpty() ? null : getFormat(mFormats.size() - 1), + RendererCapabilities.FORMAT_HANDLED); + } + } + + @Override + protected void resetRenderer() { + if (DEBUG) { + Log.d(LOGTAG, "[resetRenderer] mInitialized = " + mInitialized); + } + if (mInitialized) { + mRendererReconfigured = false; + mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + mInputBuffer = null; + mCSDInfo = null; + mInitialized = false; + } + } + + @Override + protected void handleReconfiguration(final DecoderInputBuffer bufferForRead) { + // For adaptive reconfiguration OMX decoders expect all reconfiguration + // data to be supplied at the start of the buffer that also contains + // the first frame in the new format. + assertTrue(mFormats.size() > 0); + if (mRendererReconfigurationState == RECONFIGURATION_STATE.WRITE_PENDING) { + if (bufferForRead.data == null) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][WRITE_PENDING] bufferForRead.data is not initialized."); + } + return; + } + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][WRITE_PENDING] put initialization data"); + } + final Format currentFormat = mFormats.get(mFormats.size() - 1); + for (int i = 0; i < currentFormat.initializationData.size(); i++) { + final byte[] data = currentFormat.initializationData.get(i); + bufferForRead.data.put(data); + } + mRendererReconfigurationState = RECONFIGURATION_STATE.QUEUE_PENDING; + } + } + + @Override + protected void handleFormatRead(final DecoderInputBuffer bufferForRead) + throws ExoPlaybackException { + if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] 2 formats in a row."); + } + // We received two formats in a row. Clear the current buffer of any reconfiguration data + // associated with the first format. + bufferForRead.clear(); + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + onInputFormatChanged(mFormatHolder.format); + } + + @Override + protected void handleEndOfStream(final DecoderInputBuffer bufferForRead) { + if (mRendererReconfigurationState == RECONFIGURATION_STATE.QUEUE_PENDING) { + if (DEBUG) { + Log.d(LOGTAG, "[feedInput][QUEUE_PENDING] isEndOfStream."); + } + // We received a new format immediately before the end of the stream. We need to clear + // the corresponding reconfiguration data from the current buffer, but re-write it into + // a subsequent buffer if there are any (e.g. if the user seeks backwards). + bufferForRead.clear(); + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + mInputStreamEnded = true; + final GeckoHLSSample sample = GeckoHLSSample.EOS; + calculatDuration(sample); + } + + @Override + protected void handleSamplePreparation(final DecoderInputBuffer bufferForRead) { + final int csdInfoSize = mCSDInfo != null ? mCSDInfo.length : 0; + final int dataSize = bufferForRead.data.limit(); + final int size = bufferForRead.isKeyFrame() ? csdInfoSize + dataSize : dataSize; + final byte[] realData = new byte[size]; + if (bufferForRead.isKeyFrame()) { + // Prepend the CSD information to the sample if it's a key frame. + System.arraycopy(mCSDInfo, 0, realData, 0, csdInfoSize); + bufferForRead.data.get(realData, csdInfoSize, dataSize); + } else { + bufferForRead.data.get(realData, 0, dataSize); + } + final ByteBuffer buffer = ByteBuffer.wrap(realData); + mInputBuffer = bufferForRead.data; + mInputBuffer.clear(); + + final CryptoInfo cryptoInfo = + bufferForRead.isEncrypted() ? bufferForRead.cryptoInfo.getFrameworkCryptoInfoV16() : null; + final BufferInfo bufferInfo = new BufferInfo(); + // Flags in DecoderInputBuffer are synced with MediaCodec Buffer flags. + int flags = 0; + flags |= bufferForRead.isKeyFrame() ? MediaCodec.BUFFER_FLAG_KEY_FRAME : 0; + flags |= bufferForRead.isEndOfStream() ? MediaCodec.BUFFER_FLAG_END_OF_STREAM : 0; + bufferInfo.set(0, size, bufferForRead.timeUs, flags); + + assertTrue(mFormats.size() > 0); + // We add a new format in the list once format changes, so the formatIndex + // should indicate to the last(latest) format. + final GeckoHLSSample sample = + GeckoHLSSample.create(buffer, bufferInfo, cryptoInfo, mFormats.size() - 1); + + // There's no duration information from the ExoPlayer's sample, we need + // to calculate it. + calculatDuration(sample); + mRendererReconfigurationState = RECONFIGURATION_STATE.NONE; + } + + @Override + protected void onPositionReset(final long positionUs, final boolean joining) { + super.onPositionReset(positionUs, joining); + if (mInitialized && mRendererReconfigured && mFormats.size() != 0) { + if (DEBUG) { + Log.d(LOGTAG, "[onPositionReset] WRITE_PENDING"); + } + // Any reconfiguration data that we put shortly before the reset + // may be invalid. We avoid this issue by sending reconfiguration + // data following every position reset. + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + } + + @Override + protected boolean clearInputSamplesQueue() { + if (DEBUG) { + Log.d(LOGTAG, "clearInputSamplesQueue"); + } + mDemuxedInputSamples.clear(); + mDemuxedNoDurationSamples.clear(); + return true; + } + + @Override + protected boolean canReconfigure(final Format oldFormat, final Format newFormat) { + final boolean canReconfig = + areAdaptationCompatible(oldFormat, newFormat) + && newFormat.width <= mCodecMaxValues.width + && newFormat.height <= mCodecMaxValues.height + && newFormat.maxInputSize <= mCodecMaxValues.inputSize; + if (DEBUG) { + Log.d(LOGTAG, "[canReconfigure] : " + canReconfig); + } + return canReconfig; + } + + @Override + protected void prepareReconfiguration() { + if (DEBUG) { + Log.d(LOGTAG, "[onInputFormatChanged] starting reconfiguration !"); + } + mRendererReconfigured = true; + mRendererReconfigurationState = RECONFIGURATION_STATE.WRITE_PENDING; + } + + @Override + protected void updateCSDInfo(final Format format) { + int size = 0; + for (int i = 0; i < format.initializationData.size(); i++) { + size += format.initializationData.get(i).length; + } + int startPos = 0; + mCSDInfo = new byte[size]; + for (int i = 0; i < format.initializationData.size(); i++) { + final byte[] data = format.initializationData.get(i); + System.arraycopy(data, 0, mCSDInfo, startPos, data.length); + startPos += data.length; + } + if (DEBUG) { + Log.d(LOGTAG, "mCSDInfo [" + Utils.bytesToHex(mCSDInfo) + "]"); + } + } + + @Override + protected void notifyPlayerInputFormatChanged(final Format newFormat) { + mPlayerEventDispatcher.onVideoInputFormatChanged(newFormat); + } + + private void calculateSamplesWithin(final GeckoHLSSample[] samples, final int range) { + // Calculate the first 'range' elements. + for (int i = 0; i < range; i++) { + // Comparing among samples in the window. + for (int j = -2; j < 14; j++) { + if (i + j >= 0 + && i + j < range + && samples[i + j].info.presentationTimeUs > samples[i].info.presentationTimeUs) { + samples[i].duration = + Math.min( + samples[i].duration, + samples[i + j].info.presentationTimeUs - samples[i].info.presentationTimeUs); + } + } + } + } + + private void calculatDuration(final GeckoHLSSample inputSample) { + /* + * NOTE : + * Since we customized renderer as a demuxer. Here we're not able to + * obtain duration from the DecoderInputBuffer as there's no duration inside. + * So we calcualte it by referring to nearby samples' timestamp. + * A temporary queue |mDemuxedNoDurationSamples| is used to queue demuxed + * samples from HlsMediaSource which have no duration information at first. + * We're choosing 16 as the comparing window size, because it's commonly + * used as a GOP size. + * Considering there're 16 demuxed samples in the _no duration_ queue already, + * e.g. |-2|-1|0|1|2|3|4|5|6|...|13| + * Once a new demuxed(No duration) sample X (17th) is put into the + * temporary queue, + * e.g. |-2|-1|0|1|2|3|4|5|6|...|13|X| + * we are able to calculate the correct duration for sample 0 by finding + * the closest but greater pts than sample 0 among these 16 samples, + * here, let's say sample -2 to 13. + */ + if (inputSample != null) { + mDemuxedNoDurationSamples.offer(inputSample); + } + final int sizeOfNoDura = mDemuxedNoDurationSamples.size(); + // A calculation window we've ever found suitable for both HLS TS & FMP4. + final int range = sizeOfNoDura >= 17 ? 17 : sizeOfNoDura; + final GeckoHLSSample[] inputArray = + mDemuxedNoDurationSamples.toArray(new GeckoHLSSample[sizeOfNoDura]); + if (range >= 17 && !mInputStreamEnded) { + calculateSamplesWithin(inputArray, range); + + final GeckoHLSSample toQueue = mDemuxedNoDurationSamples.poll(); + mDemuxedInputSamples.offer(toQueue); + if (BuildConfig.DEBUG_BUILD) { + Log.d( + LOGTAG, + "Demuxed sample PTS : " + + toQueue.info.presentationTimeUs + + ", duration :" + + toQueue.duration + + ", isKeyFrame(" + + toQueue.isKeyFrame() + + ", formatIndex(" + + toQueue.formatIndex + + "), queue size : " + + mDemuxedInputSamples.size() + + ", NoDuQueue size : " + + mDemuxedNoDurationSamples.size()); + } + } else if (mInputStreamEnded) { + calculateSamplesWithin(inputArray, sizeOfNoDura); + + // NOTE : We're not able to calculate the duration for the last sample. + // A workaround here is to assign a close duration to it. + long prevDuration = 33333; + GeckoHLSSample sample = null; + for (sample = mDemuxedNoDurationSamples.poll(); + sample != null; + sample = mDemuxedNoDurationSamples.poll()) { + if (sample.duration == Long.MAX_VALUE) { + sample.duration = prevDuration; + if (DEBUG) { + Log.d(LOGTAG, "Adjust the PTS of the last sample to " + sample.duration + " (us)"); + } + } + prevDuration = sample.duration; + if (DEBUG) { + Log.d( + LOGTAG, + "last loop to offer samples - PTS : " + + sample.info.presentationTimeUs + + ", Duration : " + + sample.duration + + ", isEOS : " + + sample.isEOS()); + } + mDemuxedInputSamples.offer(sample); + } + } + } + + // Return the time of first keyframe sample in the queue. + // If there's no key frame in the queue, return the MAX_VALUE so + // MFR won't mistake for that which the decode is getting slow. + public long getNextKeyFrameTime() { + long nextKeyFrameTime = Long.MAX_VALUE; + for (final GeckoHLSSample sample : mDemuxedInputSamples) { + if (sample != null && (sample.info.flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + nextKeyFrameTime = sample.info.presentationTimeUs; + break; + } + } + return nextKeyFrameTime; + } + + @Override + protected void onStreamChanged(final Format[] formats, final long offsetUs) { + mStreamFormats = formats; + } + + private static CodecMaxValues getCodecMaxValues( + final Format format, final Format[] streamFormats) { + int maxWidth = format.width; + int maxHeight = format.height; + int maxInputSize = getMaxInputSize(format); + for (final Format streamFormat : streamFormats) { + if (areAdaptationCompatible(format, streamFormat)) { + maxWidth = Math.max(maxWidth, streamFormat.width); + maxHeight = Math.max(maxHeight, streamFormat.height); + maxInputSize = Math.max(maxInputSize, getMaxInputSize(streamFormat)); + } + } + return new CodecMaxValues(maxWidth, maxHeight, maxInputSize); + } + + private static int getMaxInputSize(final Format format) { + if (format.maxInputSize != Format.NO_VALUE) { + // The format defines an explicit maximum input size. + return format.maxInputSize; + } + + if (format.width == Format.NO_VALUE || format.height == Format.NO_VALUE) { + // We can't infer a maximum input size without video dimensions. + return Format.NO_VALUE; + } + + // Attempt to infer a maximum input size from the format. + final int maxPixels; + final int minCompressionRatio; + switch (format.sampleMimeType) { + case MimeTypes.VIDEO_H264: + // Round up width/height to an integer number of macroblocks. + maxPixels = ((format.width + 15) / 16) * ((format.height + 15) / 16) * 16 * 16; + minCompressionRatio = 2; + break; + default: + // Leave the default max input size. + return Format.NO_VALUE; + } + // Estimate the maximum input size assuming three channel 4:2:0 subsampled input frames. + return (maxPixels * 3) / (2 * minCompressionRatio); + } + + private static boolean areAdaptationCompatible(final Format first, final Format second) { + return first.sampleMimeType.equals(second.sampleMimeType) + && getRotationDegrees(first) == getRotationDegrees(second); + } + + private static int getRotationDegrees(final Format format) { + return format.rotationDegrees == Format.NO_VALUE ? 0 : format.rotationDegrees; + } + + private static final class CodecMaxValues { + public final int width; + public final int height; + public final int inputSize; + + public CodecMaxValues(final int width, final int height, final int inputSize) { + this.width = width; + this.height = height; + this.inputSize = inputSize; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java new file mode 100644 index 0000000000..543f85da68 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrm.java @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCrypto; + +public interface GeckoMediaDrm { + public interface Callbacks { + void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + void onSessionUpdated(int promiseId, byte[] sessionId); + + void onSessionClosed(int promiseId, byte[] sessionId); + + void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + void onSessionError(byte[] sessionId, String message); + + void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + // All failure cases should go through this function. + void onRejectPromise(int promiseId, String message); + } + + void setCallbacks(Callbacks callbacks); + + void createSession(int createSessionToken, int promiseId, String initDataType, byte[] initData); + + void updateSession(int promiseId, String sessionId, byte[] response); + + void closeSession(int promiseId, String sessionId); + + void release(); + + MediaCrypto getMediaCrypto(); + + void setServerCertificate(final byte[] cert); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java new file mode 100644 index 0000000000..e5380bbb5c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV21.java @@ -0,0 +1,771 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.media.DeniedByServerException; +import android.media.MediaCrypto; +import android.media.MediaDrm; +import android.media.NotProvisionedException; +import android.os.AsyncTask; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; +import androidx.annotation.RequiresApi; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URLEncoder; +import java.nio.ByteBuffer; +import java.nio.charset.Charset; +import java.util.ArrayDeque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.UUID; +import org.mozilla.gecko.util.ProxySelector; + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +public class GeckoMediaDrmBridgeV21 implements GeckoMediaDrm { + protected final String LOGTAG; + private static final String INVALID_SESSION_ID = "Invalid"; + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + private static final int MAX_PROMISE_ID = Integer.MAX_VALUE; + // MediaDrm.KeyStatus information listener is supported on M+, adding a + // dummy key id to report key status. + private static final byte[] DUMMY_KEY_ID = new byte[] {0}; + + public static final Charset UTF_8 = Charset.forName("UTF-8"); + + private UUID mSchemeUUID; + private Handler mHandler; + PostRequestTask mProvisionTask; + private HandlerThread mHandlerThread; + private ByteBuffer mCryptoSessionId; + + // mProvisioningPromiseId is great than 0 only during provisioning. + private int mProvisioningPromiseId; + private HashSet<ByteBuffer> mSessionIds; + private HashMap<ByteBuffer, String> mSessionMIMETypes; + private ArrayDeque<PendingCreateSessionData> mPendingCreateSessionDataQueue; + private PendingKeyRequest mPendingKeyRequest; + private GeckoMediaDrm.Callbacks mCallbacks; + + private MediaCrypto mCrypto; + protected MediaDrm mDrm; + + public static final int LICENSE_REQUEST_INITIAL = 0; /*MediaKeyMessageType::License_request*/ + public static final int LICENSE_REQUEST_RENEWAL = 1; /*MediaKeyMessageType::License_renewal*/ + public static final int LICENSE_REQUEST_RELEASE = 2; /*MediaKeyMessageType::License_release*/ + + // Store session data while provisioning + private static class PendingCreateSessionData { + public final int mToken; + public final int mPromiseId; + public final byte[] mInitData; + public final String mMimeType; + + private PendingCreateSessionData( + final int token, final int promiseId, final byte[] initData, final String mimeType) { + mToken = token; + mPromiseId = promiseId; + mInitData = initData; + mMimeType = mimeType; + } + } + + private static class PendingKeyRequest { + public final ByteBuffer mSession; + public final byte[] mData; + public final String mMimeType; + + private PendingKeyRequest(final ByteBuffer session, final byte[] data, final String mimeType) { + mSession = session; + mData = data; + mMimeType = mimeType; + } + } + + public boolean isSecureDecoderComonentRequired(final String mimeType) { + if (mCrypto != null) { + return mCrypto.requiresSecureDecoderComponent(mimeType); + } + return false; + } + + private static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + @SuppressLint("WrongConstant") + private void configureVendorSpecificProperty() { + assertTrue(mDrm != null); + if (mDrm == null) { + return; + } + // Support L3 for now + mDrm.setPropertyString("securityLevel", "L3"); + // Refer to chromium, set multi-session mode for Widevine. + if (mSchemeUUID.equals(WIDEVINE_SCHEME_UUID)) { + mDrm.setPropertyString("privacyMode", "enable"); + mDrm.setPropertyString("sessionSharing", "enable"); + } + } + + GeckoMediaDrmBridgeV21(final String keySystem) throws Exception { + LOGTAG = getClass().getSimpleName(); + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV21 ctor"); + + mProvisioningPromiseId = 0; + mSessionIds = new HashSet<ByteBuffer>(); + mSessionMIMETypes = new HashMap<ByteBuffer, String>(); + mPendingCreateSessionDataQueue = new ArrayDeque<PendingCreateSessionData>(); + + mSchemeUUID = convertKeySystemToSchemeUUID(keySystem); + mCryptoSessionId = null; + + if (DEBUG) Log.d(LOGTAG, "mSchemeUUID : " + mSchemeUUID.toString()); + + // The caller of GeckoMediaDrmBridgeV21 ctor should handle exceptions + // threw by the following steps. + mDrm = new MediaDrm(mSchemeUUID); + configureVendorSpecificProperty(); + mDrm.setOnEventListener(new MediaDrmListener()); + try { + // ensureMediaCryptoCreated may cause NotProvisionedException for the first time use. + // Need to start provisioning with a dummy promise id. + ensureMediaCryptoCreated(); + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage()); + startProvisioning(MAX_PROMISE_ID); + } + } + + @Override + public void setCallbacks(final GeckoMediaDrm.Callbacks callbacks) { + assertTrue(callbacks != null); + mCallbacks = callbacks; + } + + @Override + public void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + if (mProvisioningPromiseId > 0 && mCrypto == null) { + if (DEBUG) Log.d(LOGTAG, "Pending createSession because it's provisioning !"); + savePendingCreateSessionData( + createSessionToken, promiseId, + initData, initDataType); + return; + } + + ByteBuffer sessionId = null; + try { + final boolean hasMediaCrypto = ensureMediaCryptoCreated(); + if (!hasMediaCrypto) { + onRejectPromise(promiseId, "MediaCrypto intance is not created !"); + return; + } + + sessionId = openSession(); + if (sessionId == null) { + onRejectPromise(promiseId, "Cannot get a session id from MediaDrm !"); + return; + } + + final MediaDrm.KeyRequest request = getKeyRequest(sessionId, initData, initDataType); + if (request == null) { + mDrm.closeSession(sessionId.array()); + onRejectPromise(promiseId, "Cannot get a key request from MediaDrm !"); + return; + } + onSessionCreated(createSessionToken, promiseId, sessionId.array(), request.getData()); + onSessionMessage(sessionId.array(), LICENSE_REQUEST_INITIAL, request.getData()); + mSessionMIMETypes.put(sessionId, initDataType); + mSessionIds.add(sessionId); + if (DEBUG) + Log.d( + LOGTAG, + " StringID : " + new String(sessionId.array(), UTF_8) + " is put into mSessionIds "); + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) Log.d(LOGTAG, "Device not provisioned:" + e.getMessage()); + if (sessionId != null) { + // The promise of this createSession will be either resolved + // or rejected after provisioning. + mDrm.closeSession(sessionId.array()); + } + savePendingCreateSessionData( + createSessionToken, promiseId, + initData, initDataType); + startProvisioning(promiseId); + } + } + + @Override + public void updateSession(final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession(), sessionId = " + sessionId); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8)); + if (!sessionExists(session)) { + onRejectPromise(promiseId, "Invalid session during updateSession."); + return; + } + + try { + final byte[] keySetId = mDrm.provideKeyResponse(session.array(), response); + if (DEBUG) { + final HashMap<String, String> infoMap = mDrm.queryKeyStatus(session.array()); + for (final String strKey : infoMap.keySet()) { + final String strValue = infoMap.get(strKey); + Log.d(LOGTAG, "InfoMap : key(" + strKey + ")/value(" + strValue + ")"); + } + } + HandleKeyStatusChangeByDummyKey(sessionId); + onSessionUpdated(promiseId, session.array()); + return; + } catch (final NotProvisionedException | DeniedByServerException | IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide key response:", e); + onSessionError(session.array(), "Got exception during updateSession."); + onRejectPromise(promiseId, "Got exception during updateSession."); + } + release(); + return; + } + + @Override + public void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + if (mDrm == null) { + onRejectPromise(promiseId, "MediaDrm instance doesn't exist !!"); + return; + } + + final ByteBuffer session = ByteBuffer.wrap(sessionId.getBytes(UTF_8)); + mSessionIds.remove(session); + mDrm.closeSession(session.array()); + onSessionClosed(promiseId, session.array()); + } + + @Override + public void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + if (mProvisionTask != null) { + mProvisionTask.cancel(true); + mProvisionTask = null; + } + if (mProvisioningPromiseId > 0) { + onRejectPromise(mProvisioningPromiseId, "Releasing ... reject provisioning session."); + mProvisioningPromiseId = 0; + } + if (mPendingKeyRequest != null) { + mPendingKeyRequest = null; + } + while (!mPendingCreateSessionDataQueue.isEmpty()) { + final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + if (pendingData != null) { + onRejectPromise(pendingData.mPromiseId, "Releasing ... reject all pending sessions."); + } + } + mPendingCreateSessionDataQueue = null; + + if (mDrm != null) { + for (final ByteBuffer session : mSessionIds) { + mDrm.closeSession(session.array()); + } + mDrm.release(); + mDrm = null; + } + mSessionIds.clear(); + mSessionIds = null; + mSessionMIMETypes.clear(); + mSessionMIMETypes = null; + + mCryptoSessionId = null; + if (mCrypto != null) { + mCrypto.release(); + mCrypto = null; + } + if (mHandlerThread != null) { + mHandlerThread.quitSafely(); + mHandlerThread = null; + } + mHandler = null; + } + + @Override + public MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + return mCrypto; + } + + @SuppressLint("WrongConstant") + @Override + public void setServerCertificate(final byte[] cert) { + if (DEBUG) Log.d(LOGTAG, "setServerCertificate()"); + if (mDrm == null) { + throw new IllegalStateException("MediaDrm instance doesn't exist !!"); + } + mDrm.setPropertyByteArray("serviceCertificate", cert); + return; + } + + protected void HandleKeyStatusChangeByDummyKey(final String sessionId) { + final SessionKeyInfo[] keyInfos = new SessionKeyInfo[1]; + keyInfos[0] = new SessionKeyInfo(DUMMY_KEY_ID, MediaDrm.KeyStatus.STATUS_USABLE); + onSessionBatchedKeyChanged(sessionId.getBytes(), keyInfos); + if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + sessionId); + } + + protected void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + } + + protected void onSessionUpdated(final int promiseId, final byte[] sessionId) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionUpdated(promiseId, sessionId); + } + } + + protected void onSessionClosed(final int promiseId, final byte[] sessionId) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionClosed(promiseId, sessionId); + } + } + + protected void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + } + + protected void onSessionError(final byte[] sessionId, final String message) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionError(sessionId, message); + } + } + + protected void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } + + protected void onRejectPromise(final int promiseId, final String message) { + assertTrue(mCallbacks != null); + if (mCallbacks != null) { + mCallbacks.onRejectPromise(promiseId, message); + } + } + + private MediaDrm.KeyRequest getKeyRequest( + final ByteBuffer aSession, final byte[] data, final String mimeType) + throws android.media.NotProvisionedException { + if (mProvisioningPromiseId > 0) { + if (DEBUG) Log.d(LOGTAG, "Now provisioning"); + return null; + } + + try { + final HashMap<String, String> optionalParameters = new HashMap<String, String>(); + return mDrm.getKeyRequest( + aSession.array(), data, mimeType, MediaDrm.KEY_TYPE_STREAMING, optionalParameters); + } catch (final Exception e) { + Log.e(LOGTAG, "Got excpetion during MediaDrm.getKeyRequest", e); + } + return null; + } + + private class MediaDrmListener implements MediaDrm.OnEventListener { + @Override + public void onEvent( + final MediaDrm mediaDrm, + final byte[] sessionArray, + final int event, + final int extra, + final byte[] data) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener.onEvent()"); + if (sessionArray == null) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Null session."); + return; + } + final ByteBuffer session = ByteBuffer.wrap(sessionArray); + if (!sessionExists(session)) { + if (DEBUG) Log.d(LOGTAG, "MediaDrmListener: Invalid session."); + return; + } + // On L, these events are treated as exceptions and handled correspondingly. + // Leaving this code block for logging message. + switch (event) { + case MediaDrm.EVENT_PROVISION_REQUIRED: + if (DEBUG) Log.d(LOGTAG, "MediaDrm.EVENT_PROVISION_REQUIRED"); + break; + case MediaDrm.EVENT_KEY_REQUIRED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_KEY_REQUIRED, sessionId=" + new String(session.array(), UTF_8)); + final String mimeType = mSessionMIMETypes.get(session); + MediaDrm.KeyRequest request = null; + try { + request = getKeyRequest(session, data, mimeType); + } catch (final android.media.NotProvisionedException e) { + Log.w(LOGTAG, "MediaDrm.EVENT_KEY_REQUIRED, Device not provisioned.", e); + startProvisioning(MAX_PROMISE_ID); + mPendingKeyRequest = new PendingKeyRequest(session, data, mimeType); + return; + } + requestLicense(sessionArray, request); + break; + case MediaDrm.EVENT_KEY_EXPIRED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_KEY_EXPIRED, sessionId=" + new String(session.array(), UTF_8)); + break; + case MediaDrm.EVENT_VENDOR_DEFINED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_VENDOR_DEFINED, sessionId=" + new String(session.array(), UTF_8)); + break; + case MediaDrm.EVENT_SESSION_RECLAIMED: + if (DEBUG) + Log.d( + LOGTAG, + "MediaDrm.EVENT_SESSION_RECLAIMED, sessionId=" + + new String(session.array(), UTF_8)); + break; + default: + if (DEBUG) Log.d(LOGTAG, "Invalid DRM event " + event); + return; + } + } + } + + private ByteBuffer openSession() throws android.media.NotProvisionedException { + try { + final byte[] sessionId = mDrm.openSession(); + // ByteBuffer.wrap() is backed by the byte[]. Make a clone here in + // case the underlying byte[] is modified. + return ByteBuffer.wrap(sessionId.clone()); + } catch (final android.media.NotProvisionedException e) { + // Throw NotProvisionedException so that we can startProvisioning(). + throw e; + } catch (final java.lang.RuntimeException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot open a new session:" + e.getMessage()); + release(); + return null; + } catch (final android.media.MediaDrmException e) { + // Other MediaDrmExceptions (e.g. ResourceBusyException) are not + // recoverable. + release(); + return null; + } + } + + protected boolean sessionExists(final ByteBuffer session) { + if (mCryptoSessionId == null) { + if (DEBUG) + Log.d(LOGTAG, "Session doesn't exist because media crypto session is not created."); + return false; + } + if (session == null) { + if (DEBUG) Log.d(LOGTAG, "Session is null, not in map !"); + return false; + } + return !session.equals(mCryptoSessionId) && mSessionIds.contains(session); + } + + private class PostRequestTask extends AsyncTask<Void, Void, Void> { + private static final String LOGTAG = "PostRequestTask"; + + private int mPromiseId; + private String mURL; + private byte[] mDrmRequest; + private byte[] mResponseBody; + + PostRequestTask(final int promiseId, final String url, final byte[] drmRequest) { + this.mPromiseId = promiseId; + this.mURL = url; + this.mDrmRequest = drmRequest; + } + + @Override + protected Void doInBackground(final Void... params) { + HttpURLConnection urlConnection = null; + BufferedReader in = null; + try { + final URI finalURI = + new URI(mURL + "&signedRequest=" + URLEncoder.encode(new String(mDrmRequest), "UTF-8")); + urlConnection = (HttpURLConnection) ProxySelector.openConnectionWithProxy(finalURI); + urlConnection.setRequestMethod("POST"); + if (DEBUG) Log.d(LOGTAG, "Provisioning, posting url =" + finalURI.toString()); + + // Add data + urlConnection.setRequestProperty("Accept", "*/*"); + urlConnection.setRequestProperty("User-Agent", getCDMUserAgent()); + urlConnection.setRequestProperty("Content-Type", "application/json"); + + // Execute HTTP Post Request + urlConnection.connect(); + + final int responseCode = urlConnection.getResponseCode(); + if (responseCode == HttpURLConnection.HTTP_OK) { + in = new BufferedReader(new InputStreamReader(urlConnection.getInputStream(), UTF_8)); + String inputLine; + final StringBuffer response = new StringBuffer(); + + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + mResponseBody = String.valueOf(response).getBytes(UTF_8); + if (DEBUG) Log.d(LOGTAG, "Provisioning, response received."); + if (mResponseBody != null) Log.d(LOGTAG, "response length=" + mResponseBody.length); + } else { + Log.d(LOGTAG, "Provisioning, server returned HTTP error code :" + responseCode); + } + } catch (final IOException e) { + Log.e(LOGTAG, "Got exception during posting provisioning request ...", e); + } catch (final URISyntaxException e) { + Log.e(LOGTAG, "Got exception during creating uri ...", e); + } finally { + if (urlConnection != null) { + urlConnection.disconnect(); + } + try { + if (in != null) { + in.close(); + } + } catch (final IOException e) { + Log.e(LOGTAG, "Exception during closing in ...", e); + } + } + return null; + } + + @Override + protected void onPostExecute(final Void v) { + onProvisionResponse(mPromiseId, mResponseBody); + } + } + + private boolean provideProvisionResponse(final byte[] response) { + if (response == null || response.length == 0) { + if (DEBUG) Log.d(LOGTAG, "Invalid provision response."); + return false; + } + + try { + mDrm.provideProvisionResponse(response); + return true; + } catch (final android.media.DeniedByServerException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } catch (final java.lang.IllegalStateException e) { + if (DEBUG) Log.d(LOGTAG, "Failed to provide provision response:" + e.getMessage()); + } + return false; + } + + private void savePendingCreateSessionData( + final int token, final int promiseId, final byte[] initData, final String mime) { + if (DEBUG) Log.d(LOGTAG, "savePendingCreateSessionData, promiseId : " + promiseId); + mPendingCreateSessionDataQueue.offer( + new PendingCreateSessionData(token, promiseId, initData, mime)); + } + + private void processPendingCreateSessionData() { + if (DEBUG) Log.d(LOGTAG, "processPendingCreateSessionData ... "); + + assertTrue(mProvisioningPromiseId == 0); + try { + while (!mPendingCreateSessionDataQueue.isEmpty()) { + final PendingCreateSessionData pendingData = mPendingCreateSessionDataQueue.poll(); + if (pendingData == null) { + return; + } + if (DEBUG) + Log.d(LOGTAG, "processPendingCreateSessionData, promiseId : " + pendingData.mPromiseId); + + createSession( + pendingData.mToken, + pendingData.mPromiseId, + pendingData.mMimeType, + pendingData.mInitData); + } + } catch (final Exception e) { + Log.e(LOGTAG, "Got excpetion during processPendingCreateSessionData ...", e); + } + } + + private void resumePendingOperations() { + if (mHandlerThread == null) { + mHandlerThread = new HandlerThread("PendingSessionOpsThread"); + mHandlerThread.start(); + } + if (mHandler == null) { + mHandler = new Handler(mHandlerThread.getLooper()); + } + mHandler.post( + new Runnable() { + @Override + public void run() { + if (mPendingKeyRequest != null) { + MediaDrm.KeyRequest request = null; + try { + request = + getKeyRequest( + mPendingKeyRequest.mSession, + mPendingKeyRequest.mData, + mPendingKeyRequest.mMimeType); + } catch (final NotProvisionedException e) { + Log.e(LOGTAG, "Cannot get key request after provisioning!"); + return; + } finally { + mPendingKeyRequest = null; + } + requestLicense(mPendingKeyRequest.mSession.array(), request); + } else { + processPendingCreateSessionData(); + } + } + }); + } + + private void requestLicense(final byte[] session, final MediaDrm.KeyRequest request) { + if (request == null) { + Log.e(LOGTAG, "null key request when requesting license"); + return; + } + // The EME spec says the messageType is only for optimization and optional. + // Send 'License_request' as default when it's not available. + int requestType = LICENSE_REQUEST_INITIAL; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + requestType = request.getRequestType(); + } + onSessionMessage(session, requestType, request.getData()); + } + + // Only triggered when failed on {openSession, getKeyRequest} + private void startProvisioning(final int promiseId) { + if (DEBUG) Log.d(LOGTAG, "startProvisioning()"); + if (mProvisioningPromiseId > 0) { + // Already in provisioning. + return; + } + try { + mProvisioningPromiseId = promiseId; + final MediaDrm.ProvisionRequest request = mDrm.getProvisionRequest(); + mProvisionTask = new PostRequestTask(promiseId, request.getDefaultUrl(), request.getData()); + mProvisionTask.execute(); + } catch (final Exception e) { + onRejectPromise(promiseId, "Exception happened in startProvisioning !"); + mProvisioningPromiseId = 0; + } + } + + private void onProvisionResponse(final int promiseId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "onProvisionResponse()"); + mProvisionTask = null; + mProvisioningPromiseId = 0; + final boolean success = provideProvisionResponse(response); + if (success) { + // Promise will either be resovled / rejected in createSession during + // resuming operations. + resumePendingOperations(); + } else { + onRejectPromise(promiseId, "Failed to provide provision response."); + } + } + + private boolean ensureMediaCryptoCreated() throws android.media.NotProvisionedException { + if (mCrypto != null) { + return true; + } + try { + mCryptoSessionId = openSession(); + if (mCryptoSessionId == null) { + if (DEBUG) Log.d(LOGTAG, "Cannot open session for MediaCrypto"); + return false; + } + + if (MediaCrypto.isCryptoSchemeSupported(mSchemeUUID)) { + final byte[] cryptoSessionId = mCryptoSessionId.array(); + mCrypto = new MediaCrypto(mSchemeUUID, cryptoSessionId); + mSessionIds.add(mCryptoSessionId); + if (DEBUG) + Log.d( + LOGTAG, + "MediaCrypto successfully created! - SId " + + INVALID_SESSION_ID + + ", " + + new String(cryptoSessionId, UTF_8)); + return true; + } else { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto for unsupported scheme."); + return false; + } + } catch (final android.media.MediaCryptoException e) { + if (DEBUG) Log.d(LOGTAG, "Cannot create MediaCrypto:" + e.getMessage()); + release(); + return false; + } catch (final android.media.NotProvisionedException e) { + if (DEBUG) + Log.d(LOGTAG, "ensureMediaCryptoCreated::Device not provisioned:" + e.getMessage()); + throw e; + } + } + + private UUID convertKeySystemToSchemeUUID(final String keySystem) { + if (WIDEVINE_KEY_SYSTEM.equals(keySystem)) { + return WIDEVINE_SCHEME_UUID; + } + if (DEBUG) Log.d(LOGTAG, "Cannot convert unsupported key system : " + keySystem); + return new UUID(0L, 0L); + } + + private String getCDMUserAgent() { + // This user agent is found and hard-coded in Android(L) source code and + // Chromium project. Not sure if it's gonna change in the future. + final String ua = "Widevine CDM v1.0"; + return ua; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java new file mode 100644 index 0000000000..bee2635a81 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoMediaDrmBridgeV23.java @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import static android.os.Build.VERSION_CODES.M; + +import android.annotation.TargetApi; +import android.media.MediaDrm; +import android.util.Log; +import java.util.List; + +@TargetApi(M) +public class GeckoMediaDrmBridgeV23 extends GeckoMediaDrmBridgeV21 { + private static final boolean DEBUG = false; + + GeckoMediaDrmBridgeV23(final String keySystem) throws Exception { + super(keySystem); + if (DEBUG) Log.d(LOGTAG, "GeckoMediaDrmBridgeV23 ctor"); + mDrm.setOnKeyStatusChangeListener(new KeyStatusChangeListener(), null); + } + + private class KeyStatusChangeListener implements MediaDrm.OnKeyStatusChangeListener { + @Override + public void onKeyStatusChange( + final MediaDrm mediaDrm, + final byte[] sessionId, + final List<MediaDrm.KeyStatus> keyInformation, + final boolean hasNewUsableKey) { + if (DEBUG) Log.d(LOGTAG, "[onKeyStatusChange] hasNewUsableKey = " + hasNewUsableKey); + if (keyInformation.size() == 0) { + return; + } + final SessionKeyInfo[] keyInfos = new SessionKeyInfo[keyInformation.size()]; + for (int i = 0; i < keyInformation.size(); i++) { + final MediaDrm.KeyStatus keyStatus = keyInformation.get(i); + keyInfos[i] = new SessionKeyInfo(keyStatus.getKeyId(), keyStatus.getStatusCode()); + } + onSessionBatchedKeyChanged(sessionId, keyInfos); + if (DEBUG) Log.d(LOGTAG, "Key successfully added for session " + new String(sessionId)); + } + } + + @Override + protected void HandleKeyStatusChangeByDummyKey(final String sessionId) { + // MediaDrm.KeyStatus information listener is supported on M+, there is no need to use + // dummy key id to report key status anymore. + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java new file mode 100644 index 0000000000..47278115d3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoPlayerFactory.java @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; +import androidx.annotation.NonNull; +import java.util.ArrayList; + +public final class GeckoPlayerFactory { + public static final ArrayList<BaseHlsPlayer> sPlayerList = new ArrayList<BaseHlsPlayer>(); + + static synchronized BaseHlsPlayer getPlayer() { + try { + final Class<?> cls = Class.forName("org.mozilla.gecko.media.GeckoHlsPlayer"); + final BaseHlsPlayer player = (BaseHlsPlayer) cls.newInstance(); + sPlayerList.add(player); + return player; + } catch (final Exception e) { + Log.e("GeckoPlayerFactory", "Class GeckoHlsPlayer not found or failed to create", e); + } + return null; + } + + static synchronized BaseHlsPlayer getPlayer(final int id) { + for (final BaseHlsPlayer player : sPlayerList) { + if (player.getId() == id) { + return player; + } + } + Log.w("GeckoPlayerFactory", "No player found with id : " + id); + return null; + } + + static synchronized void removePlayer(final @NonNull BaseHlsPlayer player) { + final int index = sPlayerList.indexOf(player); + if (index >= 0) { + sPlayerList.remove(player); + Log.d("GeckoPlayerFactory", "HlsPlayer with id(" + player.getId() + ") is removed."); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java new file mode 100644 index 0000000000..c641c58354 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/GeckoVideoInfo.java @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import org.mozilla.gecko.annotation.WrapForJNI; + +// A subset of the class VideoInfo in dom/media/MediaInfo.h +@WrapForJNI +public final class GeckoVideoInfo { + public final byte[] codecSpecificData; + public final byte[] extraData; + public final int displayWidth; + public final int displayHeight; + public final int pictureWidth; + public final int pictureHeight; + public final int rotation; + public final int stereoMode; + public final long duration; + public final String mimeType; + + public GeckoVideoInfo( + final int displayWidth, + final int displayHeight, + final int pictureWidth, + final int pictureHeight, + final int rotation, + final int stereoMode, + final long duration, + final String mimeType, + final byte[] extraData, + final byte[] codecSpecificData) { + this.displayWidth = displayWidth; + this.displayHeight = displayHeight; + this.pictureWidth = pictureWidth; + this.pictureHeight = pictureHeight; + this.rotation = rotation; + this.stereoMode = stereoMode; + this.duration = duration; + this.mimeType = mimeType; + this.extraData = extraData; + this.codecSpecificData = codecSpecificData; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java new file mode 100644 index 0000000000..7c5102c63d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/JellyBeanAsyncCodec.java @@ -0,0 +1,490 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.util.Log; +import android.view.Surface; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; + +// Implement async API using MediaCodec sync mode (API v16). +// This class uses internal worker thread/handler (mBufferPoller) to poll +// input and output buffer and notifies the client through callbacks. +final class JellyBeanAsyncCodec implements AsyncCodec { + private static final String LOGTAG = "GeckoAsyncCodecAPIv16"; + private static final boolean DEBUG = false; + + private static final int ERROR_CODEC = -10000; + + private abstract class CancelableHandler extends Handler { + private static final int MSG_CANCELLATION = 0x434E434C; // 'CNCL' + + protected CancelableHandler(final Looper looper) { + super(looper); + } + + protected void cancel() { + removeCallbacksAndMessages(null); + sendEmptyMessage(MSG_CANCELLATION); + // Wait until handleMessageLocked() is done. + synchronized (this) { + } + } + + protected boolean isCanceled() { + return hasMessages(MSG_CANCELLATION); + } + + // Subclass should implement this and return true if it handles msg. + // Warning: Never, ever call super.handleMessage() in this method! + protected abstract boolean handleMessageLocked(Message msg); + + public final void handleMessage(final Message msg) { + // Block cancel() during handleMessageLocked(). + synchronized (this) { + if (isCanceled() || handleMessageLocked(msg)) { + return; + } + } + + switch (msg.what) { + case MSG_CANCELLATION: + // Just a marker. Nothing to do here. + if (DEBUG) { + Log.d( + LOGTAG, + "handler " + this + " done cancellation, codec=" + JellyBeanAsyncCodec.this); + } + break; + default: + super.handleMessage(msg); + break; + } + } + } + + // A handler to invoke AsyncCodec.Callbacks methods. + private final class CallbackSender extends CancelableHandler { + private static final int MSG_INPUT_BUFFER_AVAILABLE = 1; + private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2; + private static final int MSG_OUTPUT_FORMAT_CHANGE = 3; + private static final int MSG_ERROR = 4; + private Callbacks mCallbacks; + + private CallbackSender(final Looper looper, final Callbacks callbacks) { + super(looper); + mCallbacks = callbacks; + } + + public void notifyInputBuffer(final int index) { + if (isCanceled()) { + return; + } + + final Message msg = obtainMessage(MSG_INPUT_BUFFER_AVAILABLE); + msg.arg1 = index; + processMessage(msg); + } + + private void processMessage(final Message msg) { + if (Looper.myLooper() == getLooper()) { + handleMessage(msg); + } else { + sendMessage(msg); + } + } + + public void notifyOutputBuffer(final int index, final MediaCodec.BufferInfo info) { + if (isCanceled()) { + return; + } + + final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, info); + msg.arg1 = index; + processMessage(msg); + } + + public void notifyOutputFormat(final MediaFormat format) { + if (isCanceled()) { + return; + } + processMessage(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format)); + } + + public void notifyError(final int result) { + Log.e(LOGTAG, "codec error:" + result); + processMessage(obtainMessage(MSG_ERROR, result, 0)); + } + + protected boolean handleMessageLocked(final Message msg) { + switch (msg.what) { + case MSG_INPUT_BUFFER_AVAILABLE: // arg1: buffer index. + mCallbacks.onInputBufferAvailable(JellyBeanAsyncCodec.this, msg.arg1); + break; + case MSG_OUTPUT_BUFFER_AVAILABLE: // arg1: buffer index, obj: info. + mCallbacks.onOutputBufferAvailable( + JellyBeanAsyncCodec.this, msg.arg1, (MediaCodec.BufferInfo) msg.obj); + break; + case MSG_OUTPUT_FORMAT_CHANGE: // obj: output format. + mCallbacks.onOutputFormatChanged(JellyBeanAsyncCodec.this, (MediaFormat) msg.obj); + break; + case MSG_ERROR: // arg1: error code. + mCallbacks.onError(JellyBeanAsyncCodec.this, msg.arg1); + break; + default: + return false; + } + + return true; + } + } + + // Handler to poll input and output buffers using dequeue(Input|Output)Buffer(), + // with 10ms time-out. Once triggered and successfully gets a buffer, it + // will schedule next polling until EOS or failure. To prevent it from + // automatically polling more buffer, use cancel() it inherits from + // CancelableHandler. + private final class BufferPoller extends CancelableHandler { + private static final int MSG_POLL_INPUT_BUFFERS = 1; + private static final int MSG_POLL_OUTPUT_BUFFERS = 2; + + private static final long DEQUEUE_TIMEOUT_US = 10000; + + public BufferPoller(final Looper looper) { + super(looper); + } + + private void schedulePollingIfNotCanceled(final int what) { + if (isCanceled()) { + return; + } + + schedulePolling(what); + } + + private void schedulePolling(final int what) { + if (needsBuffer(what)) { + sendEmptyMessage(what); + } + } + + private boolean needsBuffer(final int what) { + if (mOutputEnded && (what == MSG_POLL_OUTPUT_BUFFERS)) { + return false; + } + + if (mInputEnded && (what == MSG_POLL_INPUT_BUFFERS)) { + return false; + } + + return true; + } + + protected boolean handleMessageLocked(final Message msg) { + try { + switch (msg.what) { + case MSG_POLL_INPUT_BUFFERS: + pollInputBuffer(); + break; + case MSG_POLL_OUTPUT_BUFFERS: + pollOutputBuffer(); + break; + default: + return false; + } + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + } + + return true; + } + + private void pollInputBuffer() { + final int result = mCodec.dequeueInputBuffer(DEQUEUE_TIMEOUT_US); + if (result >= 0) { + mCallbackSender.notifyInputBuffer(result); + } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) { + mBufferPoller.schedulePollingIfNotCanceled(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } else { + mCallbackSender.notifyError(result); + } + } + + private void pollOutputBuffer() { + boolean dequeueMoreBuffer = true; + final MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + final int result = mCodec.dequeueOutputBuffer(info, DEQUEUE_TIMEOUT_US); + if (result >= 0) { + if ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + mOutputEnded = true; + } + mCallbackSender.notifyOutputBuffer(result, info); + } else if (result == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { + mOutputBuffers = mCodec.getOutputBuffers(); + } else if (result == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + mOutputBuffers = mCodec.getOutputBuffers(); + mCallbackSender.notifyOutputFormat(mCodec.getOutputFormat()); + } else if (result == MediaCodec.INFO_TRY_AGAIN_LATER) { + // When input ended, keep polling remaining output buffer until EOS. + dequeueMoreBuffer = mInputEnded; + } else { + mCallbackSender.notifyError(result); + dequeueMoreBuffer = false; + } + + if (dequeueMoreBuffer) { + schedulePollingIfNotCanceled(MSG_POLL_OUTPUT_BUFFERS); + } + } + } + + private MediaCodec mCodec; + private ByteBuffer[] mInputBuffers; + private ByteBuffer[] mOutputBuffers; + private AsyncCodec.Callbacks mCallbacks; + private CallbackSender mCallbackSender; + + private BufferPoller mBufferPoller; + private volatile boolean mInputEnded; + private volatile boolean mOutputEnded; + + // Must be called on a thread with looper. + /* package */ JellyBeanAsyncCodec(final String name) throws IOException { + mCodec = MediaCodec.createByCodecName(name); + initBufferPoller(name + " buffer poller"); + } + + private void initBufferPoller(final String name) { + if (mBufferPoller != null) { + Log.e(LOGTAG, "poller already initialized"); + return; + } + final HandlerThread thread = new HandlerThread(name); + thread.start(); + mBufferPoller = new BufferPoller(thread.getLooper()); + if (DEBUG) { + Log.d(LOGTAG, "start poller for codec:" + this + ", thread=" + thread.getThreadId()); + } + } + + @Override + public void setCallbacks(final AsyncCodec.Callbacks callbacks, final Handler handler) { + if (callbacks == null) { + return; + } + + Looper looper = (handler == null) ? null : handler.getLooper(); + if (looper == null) { + // Use this thread if no handler supplied. + looper = Looper.myLooper(); + } + if (looper == null) { + // This thread has no looper. Use poller thread. + looper = mBufferPoller.getLooper(); + } + mCallbackSender = new CallbackSender(looper, callbacks); + if (DEBUG) { + Log.d(LOGTAG, "setCallbacks(): sender=" + mCallbackSender); + } + } + + @Override + public void configure( + final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) { + assertCallbacks(); + + mCodec.configure(format, surface, crypto, flags); + } + + @Override + public boolean isAdaptivePlaybackSupported(final String mimeType) { + return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType); + } + + @Override + public boolean isTunneledPlaybackSupported(final String mimeType) { + try { + return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP + && mCodec + .getCodecInfo() + .getCapabilitiesForType(mimeType) + .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } catch (final Exception e) { + return false; + } + } + + private void assertCallbacks() { + if (mCallbackSender == null) { + throw new IllegalStateException(LOGTAG + ": callback must be supplied with setCallbacks()."); + } + } + + @Override + public void start() { + assertCallbacks(); + + mCodec.start(); + mInputEnded = false; + mOutputEnded = false; + mInputBuffers = mCodec.getInputBuffers(); + resumeReceivingInputs(); + mOutputBuffers = mCodec.getOutputBuffers(); + } + + @Override + public void resumeReceivingInputs() { + for (int i = 0; i < mInputBuffers.length; i++) { + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + } + + @Override + public final void setBitrate(final int bps) { + if (android.os.Build.VERSION.SDK_INT >= 19) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps); + mCodec.setParameters(params); + } + } + + @Override + public final void queueInputBuffer( + final int index, + final int offset, + final int size, + final long presentationTimeUs, + final int flags) { + assertCallbacks(); + + mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT + && ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0)) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mCodec.setParameters(params); + } + + try { + mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + return; + } + + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + } + + @Override + public final void queueSecureInputBuffer( + final int index, + final int offset, + final MediaCodec.CryptoInfo cryptoInfo, + final long presentationTimeUs, + final int flags) { + assertCallbacks(); + + mInputEnded = (flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0; + + try { + mCodec.queueSecureInputBuffer(index, offset, cryptoInfo, presentationTimeUs, flags); + } catch (final IllegalStateException e) { + e.printStackTrace(); + mCallbackSender.notifyError(ERROR_CODEC); + return; + } + + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_INPUT_BUFFERS); + mBufferPoller.schedulePolling(BufferPoller.MSG_POLL_OUTPUT_BUFFERS); + } + + @Override + public final void releaseOutputBuffer(final int index, final boolean render) { + assertCallbacks(); + + mCodec.releaseOutputBuffer(index, render); + } + + @Override + public final ByteBuffer getInputBuffer(final int index) { + assertCallbacks(); + + return mInputBuffers[index]; + } + + @Override + public final ByteBuffer getOutputBuffer(final int index) { + assertCallbacks(); + + return mOutputBuffers[index]; + } + + @Override + public MediaFormat getInputFormat() { + return null; + } + + @Override + public void flush() { + assertCallbacks(); + + mInputEnded = false; + mOutputEnded = false; + cancelPendingTasks(); + mCodec.flush(); + } + + private void cancelPendingTasks() { + mBufferPoller.cancel(); + mCallbackSender.cancel(); + } + + @Override + public void stop() { + assertCallbacks(); + + cancelPendingTasks(); + mCodec.stop(); + } + + @Override + public void release() { + assertCallbacks(); + + cancelPendingTasks(); + mCallbackSender = null; + mCodec.release(); + stopBufferPoller(); + } + + private void stopBufferPoller() { + if (mBufferPoller == null) { + Log.e(LOGTAG, "no initialized poller."); + return; + } + + mBufferPoller.getLooper().quit(); + mBufferPoller = null; + + if (DEBUG) { + Log.d(LOGTAG, "stop poller " + this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java new file mode 100644 index 0000000000..8afc96109d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/LollipopAsyncCodec.java @@ -0,0 +1,250 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.TargetApi; +import android.media.MediaCodec; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCrypto; +import android.media.MediaFormat; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; +import android.view.Surface; +import androidx.annotation.NonNull; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.util.HardwareCodecCapabilityUtils; + +@TargetApi(Build.VERSION_CODES.LOLLIPOP) +/* package */ final class LollipopAsyncCodec implements AsyncCodec { + private final MediaCodec mCodec; + + private class CodecCallback extends MediaCodec.Callback { + private final Forwarder mForwarder; + + private class Forwarder extends Handler { + private static final int MSG_INPUT_BUFFER_AVAILABLE = 1; + private static final int MSG_OUTPUT_BUFFER_AVAILABLE = 2; + private static final int MSG_OUTPUT_FORMAT_CHANGE = 3; + private static final int MSG_ERROR = 4; + + private final Callbacks mTarget; + + private Forwarder(final Looper looper, final Callbacks target) { + super(looper); + mTarget = target; + } + + @Override + public void handleMessage(final Message msg) { + switch (msg.what) { + case MSG_INPUT_BUFFER_AVAILABLE: + mTarget.onInputBufferAvailable(LollipopAsyncCodec.this, msg.arg1); // index + break; + case MSG_OUTPUT_BUFFER_AVAILABLE: + mTarget.onOutputBufferAvailable( + LollipopAsyncCodec.this, + msg.arg1, // index + (MediaCodec.BufferInfo) msg.obj); // buffer info + break; + case MSG_OUTPUT_FORMAT_CHANGE: + mTarget.onOutputFormatChanged( + LollipopAsyncCodec.this, (MediaFormat) msg.obj); // output format + break; + case MSG_ERROR: + mTarget.onError(LollipopAsyncCodec.this, msg.arg1); // error code + break; + default: + super.handleMessage(msg); + } + } + + private void onInput(final int index) { + notify(obtainMessage(MSG_INPUT_BUFFER_AVAILABLE, index, 0)); + } + + private void notify(final Message msg) { + if (Looper.myLooper() == getLooper()) { + handleMessage(msg); + } else { + sendMessage(msg); + } + } + + private void onOutput(final int index, final MediaCodec.BufferInfo info) { + final Message msg = obtainMessage(MSG_OUTPUT_BUFFER_AVAILABLE, index, 0, info); + notify(msg); + } + + private void onOutputFormatChanged(final MediaFormat format) { + notify(obtainMessage(MSG_OUTPUT_FORMAT_CHANGE, format)); + } + + private void onError(final MediaCodec.CodecException e) { + e.printStackTrace(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + notify(obtainMessage(MSG_ERROR, e.getErrorCode())); + } else { + notify(obtainMessage(MSG_ERROR, e.getLocalizedMessage())); + } + } + } + + private CodecCallback(final Callbacks callbacks, final Handler handler) { + Looper looper = (handler == null) ? null : handler.getLooper(); + if (looper == null) { + // Use this thread if no handler supplied. + looper = Looper.myLooper(); + } + if (looper == null) { + // This thread has no looper. Use main thread. + looper = Looper.getMainLooper(); + } + + mForwarder = new Forwarder(looper, callbacks); + } + + @Override + public void onInputBufferAvailable(@NonNull final MediaCodec codec, final int index) { + mForwarder.onInput(index); + } + + @Override + public void onOutputBufferAvailable( + @NonNull final MediaCodec codec, + final int index, + @NonNull final MediaCodec.BufferInfo info) { + mForwarder.onOutput(index, info); + } + + @Override + public void onOutputFormatChanged( + @NonNull final MediaCodec codec, @NonNull final MediaFormat format) { + mForwarder.onOutputFormatChanged(format); + } + + @Override + public void onError( + @NonNull final MediaCodec codec, @NonNull final MediaCodec.CodecException e) { + mForwarder.onError(e); + } + } + + /* package */ LollipopAsyncCodec(final String name) throws IOException { + mCodec = MediaCodec.createByCodecName(name); + } + + @Override + public void setCallbacks(final Callbacks callbacks, final Handler handler) { + if (callbacks == null) { + return; + } + + mCodec.setCallback(new CodecCallback(callbacks, handler)); + } + + @Override + public void configure( + final MediaFormat format, final Surface surface, final MediaCrypto crypto, final int flags) { + mCodec.configure(format, surface, crypto, flags); + } + + @Override + public boolean isAdaptivePlaybackSupported(final String mimeType) { + return HardwareCodecCapabilityUtils.checkSupportsAdaptivePlayback(mCodec, mimeType); + } + + @Override + public boolean isTunneledPlaybackSupported(final String mimeType) { + try { + return mCodec + .getCodecInfo() + .getCapabilitiesForType(mimeType) + .isFeatureSupported(CodecCapabilities.FEATURE_TunneledPlayback); + } catch (final Exception e) { + return false; + } + } + + @Override + public void start() { + mCodec.start(); + } + + @Override + public void stop() { + mCodec.stop(); + } + + @Override + public void flush() { + mCodec.flush(); + } + + @Override + public void resumeReceivingInputs() { + mCodec.start(); + } + + @Override + public void setBitrate(final int bps) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_VIDEO_BITRATE, bps); + mCodec.setParameters(params); + } + + @Override + public void release() { + mCodec.release(); + } + + @Override + public ByteBuffer getInputBuffer(final int index) { + return mCodec.getInputBuffer(index); + } + + @Override + public ByteBuffer getOutputBuffer(final int index) { + return mCodec.getOutputBuffer(index); + } + + @Override + public MediaFormat getInputFormat() { + return mCodec.getInputFormat(); + } + + @Override + public void queueInputBuffer( + final int index, + final int offset, + final int size, + final long presentationTimeUs, + final int flags) { + if ((flags & MediaCodec.BUFFER_FLAG_KEY_FRAME) != 0) { + final Bundle params = new Bundle(); + params.putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0); + mCodec.setParameters(params); + } + mCodec.queueInputBuffer(index, offset, size, presentationTimeUs, flags); + } + + @Override + public void queueSecureInputBuffer( + final int index, + final int offset, + final MediaCodec.CryptoInfo info, + final long presentationTimeUs, + final int flags) { + mCodec.queueSecureInputBuffer(index, offset, info, presentationTimeUs, flags); + } + + @Override + public void releaseOutputBuffer(final int index, final boolean render) { + mCodec.releaseOutputBuffer(index, render); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java new file mode 100644 index 0000000000..7be8be6236 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaDrmProxy.java @@ -0,0 +1,298 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.annotation.SuppressLint; +import android.media.MediaCrypto; +import android.media.MediaDrm; +import android.os.Build; +import android.util.Log; +import java.util.ArrayList; +import java.util.UUID; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +public final class MediaDrmProxy { + private static final String LOGTAG = "GeckoMediaDrmProxy"; + private static final boolean DEBUG = false; + private static final UUID WIDEVINE_SCHEME_UUID = + new UUID(0xedef8ba979d64aceL, 0xa3c827dcd51d21edL); + + private static final String WIDEVINE_KEY_SYSTEM = "com.widevine.alpha"; + @WrapForJNI private static final String AAC = "audio/mp4a-latm"; + @WrapForJNI private static final String AVC = "video/avc"; + @WrapForJNI private static final String VORBIS = "audio/vorbis"; + @WrapForJNI private static final String VP8 = "video/x-vnd.on2.vp8"; + @WrapForJNI private static final String VP9 = "video/x-vnd.on2.vp9"; + @WrapForJNI private static final String OPUS = "audio/opus"; + @WrapForJNI private static final String FLAC = "audio/flac"; + + public static final ArrayList<MediaDrmProxy> sProxyList = new ArrayList<MediaDrmProxy>(); + + // A flag to avoid using the native object that has been destroyed. + private boolean mDestroyed; + private GeckoMediaDrm mImpl; + private String mDrmStubId; + + private static boolean isSystemSupported() { + // Support versions >= Marshmallow + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + if (DEBUG) + Log.d(LOGTAG, "System Not supported !!, current SDK version is " + Build.VERSION.SDK_INT); + return false; + } + return true; + } + + @SuppressLint("NewApi") + @WrapForJNI + public static boolean isSchemeSupported(final String keySystem) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID) + && MediaCrypto.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID); + } + if (DEBUG) Log.d(LOGTAG, "isSchemeSupported key sytem = " + keySystem); + return false; + } + + @SuppressLint("NewApi") + @WrapForJNI + public static boolean IsCryptoSchemeSupported(final String keySystem, final String container) { + if (!isSystemSupported()) { + return false; + } + if (keySystem.equals(WIDEVINE_KEY_SYSTEM)) { + return MediaDrm.isCryptoSchemeSupported(WIDEVINE_SCHEME_UUID, container); + } + if (DEBUG) + Log.d(LOGTAG, "cannot decrypt key sytem = " + keySystem + ", container = " + container); + return false; + } + + // Interface for callback to native. + public interface Callbacks { + void onSessionCreated(int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + void onSessionUpdated(int promiseId, byte[] sessionId); + + void onSessionClosed(int promiseId, byte[] sessionId); + + void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + void onSessionError(byte[] sessionId, String message); + + // MediaDrm.KeyStatus is available in API level 23(M) + // https://developer.android.com/reference/android/media/MediaDrm.KeyStatus.html + // For compatibility between L and M above, we'll unwrap the KeyStatus structure + // and store the keyid and status into SessionKeyInfo and pass to native(MediaDrmCDMProxy). + void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + + void onRejectPromise(int promiseId, String message); + } // Callbacks + + public static class NativeMediaDrmProxyCallbacks extends JNIObject implements Callbacks { + @WrapForJNI(calledFrom = "gecko") + NativeMediaDrmProxyCallbacks() {} + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionCreated( + int createSessionToken, int promiseId, byte[] sessionId, byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionUpdated(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionClosed(int promiseId, byte[] sessionId); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionMessage(byte[] sessionId, int sessionMessageType, byte[] request); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionError(byte[] sessionId, String message); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onSessionBatchedKeyChanged(byte[] sessionId, SessionKeyInfo[] keyInfos); + + @Override + @WrapForJNI(dispatchTo = "gecko") + public native void onRejectPromise(int promiseId, String message); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } // NativeMediaDrmProxyCallbacks + + // A proxy to callback from LocalMediaDrmBridge to native instance. + public static class MediaDrmProxyCallbacks implements GeckoMediaDrm.Callbacks { + private final Callbacks mNativeCallbacks; + private final MediaDrmProxy mProxy; + + public MediaDrmProxyCallbacks(final MediaDrmProxy proxy, final Callbacks callbacks) { + mNativeCallbacks = callbacks; + mProxy = proxy; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionUpdated(promiseId, sessionId); + } + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionClosed(promiseId, sessionId); + } + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionError(sessionId, message); + } + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + if (!mProxy.isDestroyed()) { + mNativeCallbacks.onRejectPromise(promiseId, message); + } + } + } // MediaDrmProxyCallbacks + + public boolean isDestroyed() { + return mDestroyed; + } + + @WrapForJNI(calledFrom = "gecko") + public static MediaDrmProxy create(final String keySystem, final Callbacks nativeCallbacks) { + final MediaDrmProxy proxy = new MediaDrmProxy(keySystem, nativeCallbacks); + return proxy; + } + + MediaDrmProxy(final String keySystem, final Callbacks nativeCallbacks) { + if (DEBUG) Log.d(LOGTAG, "Constructing MediaDrmProxy"); + try { + mDrmStubId = UUID.randomUUID().toString(); + final IMediaDrmBridge remoteBridge = + RemoteManager.getInstance().createRemoteMediaDrmBridge(keySystem, mDrmStubId); + mImpl = new RemoteMediaDrmBridge(remoteBridge); + mImpl.setCallbacks(new MediaDrmProxyCallbacks(this, nativeCallbacks)); + sProxyList.add(this); + } catch (final Exception e) { + Log.e(LOGTAG, "Constructing MediaDrmProxy ... error", e); + } + } + + @WrapForJNI + private void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession, promiseId = " + promiseId); + mImpl.createSession(createSessionToken, promiseId, initDataType, initData); + } + + @WrapForJNI + private void updateSession(final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) + Log.d(LOGTAG, "updateSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.updateSession(promiseId, sessionId, response); + } + + @WrapForJNI + private void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) + Log.d(LOGTAG, "closeSession, primiseId(" + promiseId + "sessionId(" + sessionId + ")"); + mImpl.closeSession(promiseId, sessionId); + } + + @WrapForJNI(calledFrom = "gecko") + private String getStubId() { + return mDrmStubId; + } + + @WrapForJNI + public boolean setServerCertificate(final byte[] cert) { + try { + mImpl.setServerCertificate(cert); + return true; + } catch (final RuntimeException e) { + return false; + } + } + + // Get corresponding MediaCrypto object by a generated UUID for MediaCodec. + // Will be called on MediaFormatReader's TaskQueue. + @WrapForJNI + public static MediaCrypto getMediaCrypto(final String stubId) { + for (final MediaDrmProxy proxy : sProxyList) { + if (proxy.getStubId().equals(stubId)) { + return proxy.getMediaCryptoFromBridge(); + } + } + if (DEBUG) Log.d(LOGTAG, " NULL crytpo "); + return null; + } + + @WrapForJNI // Called when natvie object is destroyed. + private void destroy() { + if (DEBUG) Log.d(LOGTAG, "destroy!! Native object is destroyed."); + if (mDestroyed) { + return; + } + mDestroyed = true; + release(); + } + + private void release() { + if (DEBUG) Log.d(LOGTAG, "release"); + sProxyList.remove(this); + mImpl.release(); + } + + private MediaCrypto getMediaCryptoFromBridge() { + return mImpl != null ? mImpl.getMediaCrypto() : null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java new file mode 100644 index 0000000000..ef4fdc6932 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/MediaManager.java @@ -0,0 +1,79 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.os.Process; +import android.os.RemoteException; +import android.util.Log; +import org.mozilla.gecko.mozglue.GeckoLoader; +import org.mozilla.geckoview.BuildConfig; + +public final class MediaManager extends Service { + private static final String LOGTAG = "GeckoMediaManager"; + private static final boolean DEBUG = !BuildConfig.MOZILLA_OFFICIAL; + private static boolean sNativeLibLoaded; + private int mNumActiveRequests = 0; + + private Binder mBinder = + new IMediaManager.Stub() { + @Override + public ICodec createCodec() throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "request codec. Current active requests:" + mNumActiveRequests); + mNumActiveRequests++; + return new Codec(); + } + + @Override + public IMediaDrmBridge createRemoteMediaDrmBridge( + final String keySystem, final String stubId) throws RemoteException { + if (DEBUG) + Log.d(LOGTAG, "request DRM bridge. Current active requests:" + mNumActiveRequests); + mNumActiveRequests++; + return new RemoteMediaDrmBridgeStub(keySystem, stubId); + } + + @Override + public void endRequest() { + if (DEBUG) Log.d(LOGTAG, "end request. Current active requests:" + mNumActiveRequests); + if (mNumActiveRequests > 0) { + mNumActiveRequests--; + } else { + final RuntimeException e = + new RuntimeException("unmatched codec/DRM bridge creation and ending calls!"); + Log.e(LOGTAG, "Error:", e); + } + } + }; + + @Override + public synchronized void onCreate() { + if (!sNativeLibLoaded) { + GeckoLoader.doLoadLibrary(this, "mozglue"); + GeckoLoader.suppressCrashDialog(); + sNativeLibLoaded = true; + } + } + + @Override + public IBinder onBind(final Intent intent) { + return mBinder; + } + + @Override + public boolean onUnbind(final Intent intent) { + Log.i(LOGTAG, "Media service has been unbound. Stopping."); + stopSelf(); + if (mNumActiveRequests != 0) { + // Not unbound by RemoteManager -- caller process is dead. + Log.w(LOGTAG, "unbound while client still active."); + Process.killProcess(Process.myPid()); + } + return false; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java new file mode 100644 index 0000000000..62026f534f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteManager.java @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.media.MediaFormat; +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import java.util.LinkedList; +import java.util.List; +import java.util.NoSuchElementException; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.TelemetryUtils; +import org.mozilla.gecko.gfx.GeckoSurface; + +public final class RemoteManager implements IBinder.DeathRecipient { + private static final String LOGTAG = "GeckoRemoteManager"; + private static final boolean DEBUG = false; + private static RemoteManager sRemoteManager = null; + + public static synchronized RemoteManager getInstance() { + if (sRemoteManager == null) { + sRemoteManager = new RemoteManager(); + } + + sRemoteManager.init(); + return sRemoteManager; + } + + private List<CodecProxy> mCodecs = new LinkedList<CodecProxy>(); + private List<IMediaDrmBridge> mDrmBridges = new LinkedList<IMediaDrmBridge>(); + + private volatile IMediaManager mRemote; + + private final class RemoteConnection implements ServiceConnection { + @Override + public void onServiceConnected(final ComponentName name, final IBinder service) { + if (DEBUG) Log.d(LOGTAG, "service connected"); + try { + service.linkToDeath(RemoteManager.this, 0); + } catch (final RemoteException e) { + e.printStackTrace(); + } + synchronized (this) { + mRemote = IMediaManager.Stub.asInterface(service); + notify(); + } + } + + @Override + public void onServiceDisconnected(final ComponentName name) { + if (DEBUG) Log.d(LOGTAG, "service disconnected"); + unlink(); + } + + private boolean connect() { + final Context appCtxt = GeckoAppShell.getApplicationContext(); + appCtxt.bindService( + new Intent(appCtxt, MediaManager.class), + mConnection, + Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT); + waitConnect(); + return mRemote != null; + } + + // Wait up to 5s. + private synchronized void waitConnect() { + int waitCount = 0; + while (mRemote == null && waitCount < 5) { + try { + wait(1000); + waitCount++; + } catch (final InterruptedException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + if (DEBUG) { + Log.d( + LOGTAG, + "wait ~" + waitCount + "s for connection: " + (mRemote == null ? "fail" : "ok")); + } + } + + private synchronized void waitDisconnect() { + while (mRemote != null) { + try { + wait(1000); + } catch (final InterruptedException e) { + if (DEBUG) { + e.printStackTrace(); + } + } + } + } + + private synchronized void unlink() { + if (mRemote == null) { + return; + } + try { + mRemote.asBinder().unlinkToDeath(RemoteManager.this, 0); + } catch (final NoSuchElementException e) { + Log.w(LOGTAG, "death recipient already released"); + } + mRemote = null; + notify(); + } + } + + RemoteConnection mConnection = new RemoteConnection(); + + private synchronized boolean init() { + if (mRemote != null) { + return true; + } + + if (DEBUG) Log.d(LOGTAG, "init remote manager " + this); + return mConnection.connect(); + } + + public synchronized CodecProxy createCodec( + final boolean isEncoder, + final MediaFormat format, + final GeckoSurface surface, + final CodecProxy.Callbacks callbacks, + final String drmStubId) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createCodec failed due to not initialize"); + return null; + } + try { + final ICodec remote = mRemote.createCodec(); + final CodecProxy proxy = + CodecProxy.createCodecProxy(isEncoder, format, surface, callbacks, drmStubId); + if (proxy.init(remote)) { + mCodecs.add(proxy); + return proxy; + } else { + return null; + } + } catch (final RemoteException e) { + e.printStackTrace(); + return null; + } + } + + public synchronized IMediaDrmBridge createRemoteMediaDrmBridge( + final String keySystem, final String stubId) { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "createRemoteMediaDrmBridge failed due to not initialize"); + return null; + } + try { + final IMediaDrmBridge remoteBridge = mRemote.createRemoteMediaDrmBridge(keySystem, stubId); + mDrmBridges.add(remoteBridge); + return remoteBridge; + } catch (final RemoteException e) { + Log.e(LOGTAG, "Got exception during createRemoteMediaDrmBridge().", e); + return null; + } + } + + @Override + public void binderDied() { + Log.e(LOGTAG, "remote codec is dead"); + TelemetryUtils.addToHistogram("MEDIA_DECODING_PROCESS_CRASH", 1); + handleRemoteDeath(); + } + + private synchronized void handleRemoteDeath() { + mConnection.waitDisconnect(); + + if (init() && recoverRemoteCodec()) { + notifyError(false); + } else { + notifyError(true); + } + } + + private synchronized void notifyError(final boolean fatal) { + for (final CodecProxy proxy : mCodecs) { + proxy.reportError(fatal); + } + } + + private synchronized boolean recoverRemoteCodec() { + if (DEBUG) Log.d(LOGTAG, "recover codec"); + boolean ok = true; + try { + for (final CodecProxy proxy : mCodecs) { + ok &= proxy.init(mRemote.createCodec()); + } + return ok; + } catch (final RemoteException e) { + return false; + } + } + + public void releaseCodec(final CodecProxy proxy) throws DeadObjectException, RemoteException { + if (mRemote == null) { + if (DEBUG) Log.d(LOGTAG, "releaseCodec called but not initialized yet"); + return; + } + proxy.deinit(); + synchronized (this) { + if (mCodecs.remove(proxy)) { + try { + mRemote.endRequest(); + releaseIfNeeded(); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "fail to report remote codec disconnection"); + } + } + } + } + + private void releaseIfNeeded() { + if (!mCodecs.isEmpty() || !mDrmBridges.isEmpty()) { + return; + } + + if (DEBUG) Log.d(LOGTAG, "release remote manager " + this); + mConnection.unlink(); + final Context appCtxt = GeckoAppShell.getApplicationContext(); + appCtxt.unbindService(mConnection); + } + + public void onRemoteMediaDrmBridgeReleased(final IMediaDrmBridge remote) { + if (!mDrmBridges.contains(remote)) { + Log.e(LOGTAG, "Try to release unknown remote MediaDrm bridge: " + remote); + return; + } + + synchronized (this) { + if (mDrmBridges.remove(remote)) { + try { + mRemote.endRequest(); + releaseIfNeeded(); + } catch (final RemoteException | NullPointerException e) { + Log.e(LOGTAG, "Fail to report remote DRM bridge disconnection"); + } + } + } + } +} // RemoteManager diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java new file mode 100644 index 0000000000..b90f720300 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridge.java @@ -0,0 +1,163 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCrypto; +import android.util.Log; + +final class RemoteMediaDrmBridge implements GeckoMediaDrm { + private static final String LOGTAG = "RemoteMediaDrmBridge"; + private static final boolean DEBUG = false; + private CallbacksForwarder mCallbacksFwd; + private IMediaDrmBridge mRemote; + + // Forward callbacks from remote bridge stub to MediaDrmProxy. + private static class CallbacksForwarder extends IMediaDrmBridgeCallbacks.Stub { + private final GeckoMediaDrm.Callbacks mProxyCallbacks; + + CallbacksForwarder(final Callbacks callbacks) { + assertTrue(callbacks != null); + mProxyCallbacks = callbacks; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + mProxyCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + mProxyCallbacks.onSessionUpdated(promiseId, sessionId); + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + mProxyCallbacks.onSessionClosed(promiseId, sessionId); + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + mProxyCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + mProxyCallbacks.onSessionError(sessionId, message); + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + mProxyCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + mProxyCallbacks.onRejectPromise(promiseId, message); + } + } // CallbacksForwarder + + /* package-private */ static void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + public RemoteMediaDrmBridge(final IMediaDrmBridge remoteBridge) { + assertTrue(remoteBridge != null); + mRemote = remoteBridge; + } + + @Override + public synchronized void setCallbacks(final Callbacks callbacks) { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(callbacks != null); + assertTrue(mRemote != null); + + mCallbacksFwd = new CallbacksForwarder(callbacks); + try { + mRemote.setCallbacks(mCallbacksFwd); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception during setCallbacks", e); + } + } + + @Override + public synchronized void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + + try { + mRemote.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while creating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to create session."); + } + } + + @Override + public synchronized void updateSession( + final int promiseId, final String sessionId, final byte[] response) { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + + try { + mRemote.updateSession(promiseId, sessionId, response); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while updating remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to update session."); + } + } + + @Override + public synchronized void closeSession(final int promiseId, final String sessionId) { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + + try { + mRemote.closeSession(promiseId, sessionId); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while closing remote session.", e); + mCallbacksFwd.onRejectPromise(promiseId, "Failed to close session."); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + + try { + mRemote.release(); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while releasing RemoteDrmBridge.", e); + } + RemoteManager.getInstance().onRemoteMediaDrmBridgeReleased(mRemote); + mRemote = null; + mCallbacksFwd = null; + } + + @Override + public synchronized MediaCrypto getMediaCrypto() { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto(), should not enter here!"); + assertTrue(false); + return null; + } + + @Override + public synchronized void setServerCertificate(final byte[] cert) { + try { + mRemote.setServerCertificate(cert); + } catch (final Exception e) { + Log.e(LOGTAG, "Got exception while setting server certificate.", e); + throw new RuntimeException(e); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java new file mode 100644 index 0000000000..f466529388 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/RemoteMediaDrmBridgeStub.java @@ -0,0 +1,252 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCrypto; +import android.os.Build; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import java.util.ArrayList; + +final class RemoteMediaDrmBridgeStub extends IMediaDrmBridge.Stub + implements IBinder.DeathRecipient { + private static final String LOGTAG = "RemoteDrmBridgeStub"; + private static final boolean DEBUG = false; + private volatile IMediaDrmBridgeCallbacks mCallbacks = null; + + // Underlying bridge implmenetaion, i.e. GeckoMediaDrmBrdigeV21. + private GeckoMediaDrm mBridge = null; + + // mStubId is initialized during stub construction. It should be a unique + // string which is generated in MediaDrmProxy in Fennec App process and is + // used for Codec to obtain corresponding MediaCrypto as input to achieve + // decryption. + // The generated stubId will be delivered to Codec via a code path starting + // from MediaDrmProxy -> MediaDrmCDMProxy -> RemoteDataDecoder => IPC => Codec. + private String mStubId = ""; + + public static final ArrayList<RemoteMediaDrmBridgeStub> mBridgeStubs = + new ArrayList<RemoteMediaDrmBridgeStub>(); + + private String getId() { + return mStubId; + } + + private MediaCrypto getMediaCryptoFromBridge() { + return mBridge != null ? mBridge.getMediaCrypto() : null; + } + + public static synchronized MediaCrypto getMediaCrypto(final String stubId) { + if (DEBUG) Log.d(LOGTAG, "getMediaCrypto()"); + + for (int i = 0; i < mBridgeStubs.size(); i++) { + if (mBridgeStubs.get(i) != null && mBridgeStubs.get(i).getId().equals(stubId)) { + return mBridgeStubs.get(i).getMediaCryptoFromBridge(); + } + } + return null; + } + + // Callback to RemoteMediaDrmBridge. + private final class Callbacks implements GeckoMediaDrm.Callbacks { + private IMediaDrmBridgeCallbacks mRemoteCallbacks; + + public Callbacks(final IMediaDrmBridgeCallbacks remote) { + mRemoteCallbacks = remote; + } + + @Override + public void onSessionCreated( + final int createSessionToken, + final int promiseId, + final byte[] sessionId, + final byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionCreated()"); + try { + mRemoteCallbacks.onSessionCreated(createSessionToken, promiseId, sessionId, request); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionUpdated(final int promiseId, final byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionUpdated()"); + try { + mRemoteCallbacks.onSessionUpdated(promiseId, sessionId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionClosed(final int promiseId, final byte[] sessionId) { + if (DEBUG) Log.d(LOGTAG, "onSessionClosed()"); + try { + mRemoteCallbacks.onSessionClosed(promiseId, sessionId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionMessage( + final byte[] sessionId, final int sessionMessageType, final byte[] request) { + if (DEBUG) Log.d(LOGTAG, "onSessionMessage()"); + try { + mRemoteCallbacks.onSessionMessage(sessionId, sessionMessageType, request); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionError(final byte[] sessionId, final String message) { + if (DEBUG) Log.d(LOGTAG, "onSessionError()"); + try { + mRemoteCallbacks.onSessionError(sessionId, message); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onSessionBatchedKeyChanged( + final byte[] sessionId, final SessionKeyInfo[] keyInfos) { + if (DEBUG) Log.d(LOGTAG, "onSessionBatchedKeyChanged()"); + try { + mRemoteCallbacks.onSessionBatchedKeyChanged(sessionId, keyInfos); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public void onRejectPromise(final int promiseId, final String message) { + if (DEBUG) Log.d(LOGTAG, "onRejectPromise()"); + try { + mRemoteCallbacks.onRejectPromise(promiseId, message); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + } + + /* package-private */ void assertTrue(final boolean condition) { + if (DEBUG && !condition) { + throw new AssertionError("Expected condition to be true"); + } + } + + RemoteMediaDrmBridgeStub(final String keySystem, final String stubId) throws RemoteException { + if (Build.VERSION.SDK_INT < 21) { + Log.e(LOGTAG, "Pre-Lollipop should never enter here!!"); + throw new RemoteException("Error, unsupported version!"); + } + try { + if (Build.VERSION.SDK_INT < 23) { + mBridge = new GeckoMediaDrmBridgeV21(keySystem); + } else { + mBridge = new GeckoMediaDrmBridgeV23(keySystem); + } + mStubId = stubId; + mBridgeStubs.add(this); + } catch (final Exception e) { + throw new RemoteException("RemoteMediaDrmBridgeStub cannot create bridge implementation."); + } + } + + @Override + public synchronized void setCallbacks(final IMediaDrmBridgeCallbacks callbacks) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "setCallbacks()"); + assertTrue(mBridge != null); + assertTrue(callbacks != null); + mCallbacks = callbacks; + callbacks.asBinder().linkToDeath(this, 0); + mBridge.setCallbacks(new Callbacks(mCallbacks)); + } + + @Override + public synchronized void createSession( + final int createSessionToken, + final int promiseId, + final String initDataType, + final byte[] initData) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "createSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.createSession(createSessionToken, promiseId, initDataType, initData); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to createSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to createSession."); + } + } + + @Override + public synchronized void updateSession( + final int promiseId, final String sessionId, final byte[] response) throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "updateSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.updateSession(promiseId, sessionId, response); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to updateSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to updateSession."); + } + } + + @Override + public synchronized void closeSession(final int promiseId, final String sessionId) + throws RemoteException { + if (DEBUG) Log.d(LOGTAG, "closeSession()"); + try { + assertTrue(mCallbacks != null); + assertTrue(mBridge != null); + mBridge.closeSession(promiseId, sessionId); + } catch (final Exception e) { + Log.e(LOGTAG, "Failed to closeSession.", e); + mCallbacks.onRejectPromise(promiseId, "Failed to closeSession."); + } + } + + // IBinder.DeathRecipient + @Override + public synchronized void binderDied() { + Log.e(LOGTAG, "Binder died !!"); + try { + release(); + } catch (final Exception e) { + Log.e(LOGTAG, "Exception ! Dead recipient !!", e); + } + } + + @Override + public synchronized void release() { + if (DEBUG) Log.d(LOGTAG, "release()"); + mBridgeStubs.remove(this); + if (mBridge != null) { + mBridge.release(); + mBridge = null; + } + mCallbacks.asBinder().unlinkToDeath(this, 0); + mCallbacks = null; + mStubId = ""; + } + + @Override + public synchronized void setServerCertificate(final byte[] cert) { + try { + mBridge.setServerCertificate(cert); + } catch (final IllegalStateException e) { + Log.e(LOGTAG, "Failed to setServerCertificate.", e); + throw e; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java new file mode 100644 index 0000000000..de4cbf923a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Sample.java @@ -0,0 +1,232 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.media.MediaCodec.BufferInfo; +import android.media.MediaCodec.CryptoInfo; +import android.os.Parcel; +import android.os.Parcelable; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; + +// Parcelable carrying input/output sample data and info cross process. +public final class Sample implements Parcelable { + public static final Sample EOS; + + static { + final BufferInfo eosInfo = new BufferInfo(); + EOS = new Sample(); + EOS.info.set(0, 0, Long.MIN_VALUE, MediaCodec.BUFFER_FLAG_END_OF_STREAM); + } + + @WrapForJNI public long session; + + public static final int NO_BUFFER = -1; + + public int bufferId = NO_BUFFER; + @WrapForJNI public BufferInfo info = new BufferInfo(); + public CryptoInfo cryptoInfo; + + // Simple Linked list for recycling objects. + // Used to nodify Sample objects. Do not marshal/unmarshal. + private Sample mNext; + private static Sample sPool = new Sample(); + private static int sPoolSize = 1; + + private Sample() {} + + private void readInfo(final Parcel in) { + final int offset = in.readInt(); + final int size = in.readInt(); + final long pts = in.readLong(); + final int flags = in.readInt(); + + info.set(offset, size, pts, flags); + } + + private void readCrypto(final Parcel in) { + final int hasCryptoInfo = in.readInt(); + if (hasCryptoInfo == 0) { + cryptoInfo = null; + return; + } + + final byte[] iv = in.createByteArray(); + final byte[] key = in.createByteArray(); + final int mode = in.readInt(); + final int[] numBytesOfClearData = in.createIntArray(); + final int[] numBytesOfEncryptedData = in.createIntArray(); + final int numSubSamples = in.readInt(); + + if (cryptoInfo == null) { + cryptoInfo = new CryptoInfo(); + } + cryptoInfo.set(numSubSamples, numBytesOfClearData, numBytesOfEncryptedData, key, iv, mode); + } + + public Sample set(final BufferInfo info, final CryptoInfo cryptoInfo) { + setBufferInfo(info); + setCryptoInfo(cryptoInfo); + return this; + } + + public void setBufferInfo(final BufferInfo info) { + this.info.set(0, info.size, info.presentationTimeUs, info.flags); + } + + public void setCryptoInfo(final CryptoInfo crypto) { + if (crypto == null) { + cryptoInfo = null; + return; + } + + if (cryptoInfo == null) { + cryptoInfo = new CryptoInfo(); + } + cryptoInfo.set( + crypto.numSubSamples, + crypto.numBytesOfClearData, + crypto.numBytesOfEncryptedData, + crypto.key, + crypto.iv, + crypto.mode); + } + + @WrapForJNI + public void dispose() { + if (isEOS()) { + return; + } + + bufferId = NO_BUFFER; + info.set(0, 0, 0, 0); + if (cryptoInfo != null) { + cryptoInfo.set(0, null, null, null, null, 0); + } + + // Recycle it. + synchronized (CREATOR) { + this.mNext = sPool; + sPool = this; + sPoolSize++; + } + } + + public boolean isEOS() { + return (this == EOS) || ((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0); + } + + public static Sample obtain() { + synchronized (CREATOR) { + Sample s = null; + if (sPoolSize > 0) { + s = sPool; + sPool = s.mNext; + s.mNext = null; + sPoolSize--; + } else { + s = new Sample(); + } + return s; + } + } + + public static final Creator<Sample> CREATOR = + new Creator<Sample>() { + @Override + public Sample createFromParcel(final Parcel in) { + return obtainSample(in); + } + + @Override + public Sample[] newArray(final int size) { + return new Sample[size]; + } + + private Sample obtainSample(final Parcel in) { + final Sample s = obtain(); + s.session = in.readLong(); + s.bufferId = in.readInt(); + s.readInfo(in); + s.readCrypto(in); + return s; + } + }; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int parcelableFlags) { + dest.writeLong(session); + dest.writeInt(bufferId); + writeInfo(dest); + writeCrypto(dest); + } + + private void writeInfo(final Parcel dest) { + dest.writeInt(info.offset); + dest.writeInt(info.size); + dest.writeLong(info.presentationTimeUs); + dest.writeInt(info.flags); + } + + private void writeCrypto(final Parcel dest) { + if (cryptoInfo != null) { + dest.writeInt(1); + dest.writeByteArray(cryptoInfo.iv); + dest.writeByteArray(cryptoInfo.key); + dest.writeInt(cryptoInfo.mode); + dest.writeIntArray(cryptoInfo.numBytesOfClearData); + dest.writeIntArray(cryptoInfo.numBytesOfEncryptedData); + dest.writeInt(cryptoInfo.numSubSamples); + } else { + dest.writeInt(0); + } + } + + public static byte[] byteArrayFromBuffer( + final ByteBuffer buffer, final int offset, final int size) { + if (buffer == null || buffer.capacity() == 0 || size == 0) { + return null; + } + if (buffer.hasArray() && offset == 0 && buffer.array().length == size) { + return buffer.array(); + } + final int length = Math.min(offset + size, buffer.capacity()) - offset; + final byte[] bytes = new byte[length]; + buffer.position(offset); + buffer.get(bytes); + return bytes; + } + + @Override + public String toString() { + if (isEOS()) { + return "EOS sample"; + } + + final StringBuilder str = new StringBuilder(); + str.append("{ session#:") + .append(session) + .append(", buffer#") + .append(bufferId) + .append(", info=") + .append("{ offset=") + .append(info.offset) + .append(", size=") + .append(info.size) + .append(", pts=") + .append(info.presentationTimeUs) + .append(", flags=") + .append(Integer.toHexString(info.flags)) + .append(" }") + .append(" }"); + return str.toString(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java new file mode 100644 index 0000000000..e6b242708d --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SampleBuffer.java @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.os.Parcel; +import android.os.Parcelable; +import java.io.IOException; +import java.nio.ByteBuffer; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.SharedMemory; + +public final class SampleBuffer implements Parcelable { + private SharedMemory mSharedMem; + + /* package */ + public SampleBuffer(final SharedMemory sharedMem) { + mSharedMem = sharedMem; + } + + protected SampleBuffer(final Parcel in) { + mSharedMem = in.readParcelable(SampleBuffer.class.getClassLoader()); + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeParcelable(mSharedMem, flags); + } + + @Override + public int describeContents() { + return 0; + } + + public static final Creator<SampleBuffer> CREATOR = + new Creator<SampleBuffer>() { + @Override + public SampleBuffer createFromParcel(final Parcel in) { + return new SampleBuffer(in); + } + + @Override + public SampleBuffer[] newArray(final int size) { + return new SampleBuffer[size]; + } + }; + + public int capacity() { + return mSharedMem != null ? mSharedMem.getSize() : 0; + } + + public void readFromByteBuffer(final ByteBuffer src, final int offset, final int size) + throws IOException { + if (!src.isDirect()) { + throw new IOException("SharedMemBuffer only support reading from direct byte buffer."); + } + try { + nativeReadFromDirectBuffer(src, mSharedMem.getPointer(), offset, size); + mSharedMem.flush(); + } catch (final NullPointerException e) { + throw new IOException(e); + } + } + + private static native void nativeReadFromDirectBuffer( + ByteBuffer src, long dest, int offset, int size); + + @WrapForJNI + public void writeToByteBuffer(final ByteBuffer dest, final int offset, final int size) + throws IOException { + if (!dest.isDirect()) { + throw new IOException("SharedMemBuffer only support writing to direct byte buffer."); + } + try { + nativeWriteToDirectBuffer(mSharedMem.getPointer(), dest, offset, size); + } catch (final NullPointerException e) { + throw new IOException(e); + } + } + + private static native void nativeWriteToDirectBuffer( + long src, ByteBuffer dest, int offset, int size); + + public void dispose() { + if (mSharedMem != null) { + mSharedMem.dispose(); + mSharedMem = null; + } + } + + @WrapForJNI + public boolean isValid() { + return mSharedMem != null; + } + + @Override + public String toString() { + return "Buffer: " + mSharedMem; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java new file mode 100644 index 0000000000..a2101b3aeb --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SamplePool.java @@ -0,0 +1,154 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.media.MediaCodec; +import android.util.SparseArray; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.mozglue.SharedMemory; + +final class SamplePool { + private static final class Impl { + private final String mName; + private int mDefaultBufferSize = 4096; + private final List<Sample> mRecycledSamples = new ArrayList<>(); + private final boolean mBufferless; + + private int mNextBufferId = Sample.NO_BUFFER + 1; + private SparseArray<SampleBuffer> mBuffers = new SparseArray<>(); + + private Impl(final String name, final boolean bufferless) { + mName = name; + mBufferless = bufferless; + } + + private void setDefaultBufferSize(final int size) { + if (mBufferless) { + throw new IllegalStateException("Setting buffer size of a bufferless pool is not allowed"); + } + mDefaultBufferSize = size; + } + + private synchronized Sample obtain(final int size) { + if (!mRecycledSamples.isEmpty()) { + return mRecycledSamples.remove(0); + } + + if (mBufferless) { + return Sample.obtain(); + } else { + return allocateSampleAndBuffer(size); + } + } + + private Sample allocateSampleAndBuffer(final int size) { + final int id = mNextBufferId++; + try { + final SharedMemory shm = new SharedMemory(id, Math.max(size, mDefaultBufferSize)); + mBuffers.put((Integer) id, new SampleBuffer(shm)); + final Sample s = Sample.obtain(); + s.bufferId = id; + return s; + } catch (final NoSuchMethodException | IOException e) { + mBuffers.delete(id); + throw new UnsupportedOperationException(e); + } + } + + private synchronized SampleBuffer getBuffer(final int id) { + return mBuffers.get(id); + } + + private synchronized void recycle(final Sample recycled) { + if (mBufferless || isUsefulSample(recycled)) { + mRecycledSamples.add(recycled); + } else { + disposeSample(recycled); + } + } + + private boolean isUsefulSample(final Sample sample) { + return mBuffers.get(sample.bufferId).capacity() >= mDefaultBufferSize; + } + + private synchronized void clear() { + for (final Sample s : mRecycledSamples) { + disposeSample(s); + } + mRecycledSamples.clear(); + + for (int i = 0; i < mBuffers.size(); ++i) { + mBuffers.valueAt(i).dispose(); + } + mBuffers.clear(); + } + + private void disposeSample(final Sample sample) { + if (sample.bufferId != Sample.NO_BUFFER) { + mBuffers.get(sample.bufferId).dispose(); + mBuffers.delete(sample.bufferId); + } + sample.dispose(); + } + + @Override + protected void finalize() { + clear(); + } + } + + private final Impl mInputs; + private final Impl mOutputs; + + /* package */ SamplePool(final String name, final boolean renderToSurface) { + mInputs = new Impl(name + " input sample pool", false); + // Buffers are useless when rendering to surface. + mOutputs = new Impl(name + " output sample pool", renderToSurface); + } + + /* package */ void setInputBufferSize(final int size) { + mInputs.setDefaultBufferSize(size); + } + + /* package */ void setOutputBufferSize(final int size) { + mOutputs.setDefaultBufferSize(size); + } + + /* package */ Sample obtainInput(final int size) { + final Sample input = mInputs.obtain(size); + input.info.set(0, 0, 0, 0); + return input; + } + + /* package */ Sample obtainOutput(final MediaCodec.BufferInfo info) { + final Sample output = mOutputs.obtain(info.size); + output.info.set(0, info.size, info.presentationTimeUs, info.flags); + return output; + } + + /* package */ void recycleInput(final Sample sample) { + sample.cryptoInfo = null; + mInputs.recycle(sample); + } + + /* package */ void recycleOutput(final Sample sample) { + mOutputs.recycle(sample); + } + + /* package */ void reset() { + mInputs.clear(); + mOutputs.clear(); + } + + /* package */ SampleBuffer getInputBuffer(final int id) { + return mInputs.getBuffer(id); + } + + /* package */ SampleBuffer getOutputBuffer(final int id) { + return mOutputs.getBuffer(id); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java new file mode 100644 index 0000000000..5e70a6f2a7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/SessionKeyInfo.java @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.os.Parcel; +import android.os.Parcelable; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class SessionKeyInfo implements Parcelable { + @WrapForJNI public byte[] keyId; + + @WrapForJNI public int status; + + @WrapForJNI + public SessionKeyInfo(final byte[] keyId, final int status) { + this.keyId = keyId; + this.status = status; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int parcelableFlags) { + dest.writeByteArray(keyId); + dest.writeInt(status); + } + + public static final Creator<SessionKeyInfo> CREATOR = + new Creator<SessionKeyInfo>() { + @Override + public SessionKeyInfo createFromParcel(final Parcel in) { + return new SessionKeyInfo(in); + } + + @Override + public SessionKeyInfo[] newArray(final int size) { + return new SessionKeyInfo[size]; + } + }; + + private SessionKeyInfo(final Parcel src) { + keyId = src.createByteArray(); + status = src.readInt(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java new file mode 100644 index 0000000000..5cc32e127c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/media/Utils.java @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.media; + +import android.util.Log; + +public class Utils { + public static long getThreadId() { + final Thread t = Thread.currentThread(); + return t.getId(); + } + + public static String getThreadSignature() { + final Thread t = Thread.currentThread(); + final long l = t.getId(); + final String name = t.getName(); + final long p = t.getPriority(); + final String gname = t.getThreadGroup().getName(); + return (name + ":(id)" + l + ":(priority)" + p + ":(group)" + gname); + } + + public static void logThreadSignature() { + Log.d("ThreadUtils", getThreadSignature()); + } + + private static final char[] hexArray = "0123456789ABCDEF".toCharArray(); + + public static String bytesToHex(final byte[] bytes) { + final char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + final int v = bytes[j] & 0xFF; + hexChars[j * 2] = hexArray[v >>> 4]; + hexChars[j * 2 + 1] = hexArray[v & 0x0F]; + } + return new String(hexChars); + } +} 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 0000000000..701780171e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/GeckoLoader.java @@ -0,0 +1,440 @@ +/* -*- 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 android.content.Context; +import android.os.Build; +import android.os.Environment; +import android.util.Log; +import dalvik.system.BaseDexClassLoader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.JNITarget; +import org.mozilla.gecko.annotation.RobocopTarget; + +public final class GeckoLoader { + private static final String LOGTAG = "GeckoLoader"; + + 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 getGREDir(final Context context) { + if (sGREDir == null) { + sGREDir = new File(context.getApplicationInfo().dataDir); + } + return sGREDir; + } + + 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 (final Exception e) { + Log.w(LOGTAG, "No download directory found.", e); + } + } + + private static void delTree(final File file) { + if (file.isDirectory()) { + final File[] children = file.listFiles(); + for (final File child : children) { + delTree(child); + } + } + file.delete(); + } + + private static File getTmpDir(final Context context) { + // It's important that this folder is in the cache directory so users can actually + // clear it when it gets too big. + return new File(context.getCacheDir(), "gecko_temp"); + } + + private static String escapeDoubleQuotes(final String str) { + return str.replaceAll("\"", "\\\""); + } + + private static void setupInitialPrefs(final Map<String, Object> prefs) { + if (prefs != null) { + final StringBuilder prefsEnv = new StringBuilder("MOZ_DEFAULT_PREFS="); + for (final String key : prefs.keySet()) { + final Object value = prefs.get(key); + if (value == null) { + continue; + } + prefsEnv.append(String.format("pref(\"%s\",", escapeDoubleQuotes(key))); + if (value instanceof String) { + prefsEnv.append(String.format("\"%s\"", escapeDoubleQuotes(value.toString()))); + } else if (value instanceof Boolean) { + prefsEnv.append((Boolean) value ? "true" : "false"); + } else { + prefsEnv.append(value.toString()); + } + + prefsEnv.append(");\n"); + } + + putenv(prefsEnv.toString()); + } + } + + @SuppressWarnings("deprecation") // for Build.CPU_ABI + public static synchronized void setupGeckoEnvironment( + final Context context, + final boolean isChildProcess, + final String profilePath, + final Collection<String> env, + final Map<String, Object> prefs, + final boolean xpcshell) { + for (final String e : env) { + putenv(e); + } + + putenv("MOZ_ANDROID_PACKAGE_NAME=" + context.getPackageName()); + + if (!isChildProcess) { + setupDownloadEnvironment(context); + + // profile home path + putenv("HOME=" + profilePath); + + // setup the downloads path + File f = Environment.getDownloadCacheDirectory(); + putenv("EXTERNAL_STORAGE=" + f.getPath()); + + // setup the app-specific cache path + f = context.getCacheDir(); + putenv("CACHE_DIRECTORY=" + f.getPath()); + + f = context.getExternalFilesDir(null); + if (f != null) { + putenv("PUBLIC_STORAGE=" + f.getPath()); + } + + if (Build.VERSION.SDK_INT >= 17) { + final 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); + } + } + + setupInitialPrefs(prefs); + } + + // Xpcshell tests set up their own temp directory + if (!xpcshell) { + // setup the tmp path + final File f = getTmpDir(context); + if (!f.exists()) { + f.mkdirs(); + } + putenv("TMPDIR=" + f.getPath()); + } + + putenv("LANG=" + Locale.getDefault().toString()); + + final Class<?> crashHandler = GeckoAppShell.getCrashHandlerService(); + if (crashHandler != null) { + putenv( + "MOZ_ANDROID_CRASH_HANDLER=" + context.getPackageName() + "/" + crashHandler.getName()); + } + + putenv("MOZ_ANDROID_DEVICE_SDK_VERSION=" + Build.VERSION.SDK_INT); + putenv("MOZ_ANDROID_CPU_ABI=" + Build.CPU_ABI); + + // env from extras could have reset out linker flags; set them again. + loadLibsSetupLocked(context); + } + + // Adapted from + // https://source.chromium.org/chromium/chromium/src/+/main:base/android/java/src/org/chromium/base/BundleUtils.java;l=196;drc=c0fedddd4a1444653235912cfae3d44b544ded01 + private static String getLibraryPath(final String libraryName) { + // Due to b/171269960 isolated split class loaders have an empty library path, so check + // the base module class loader first which loaded GeckoAppShell. If the library is not + // found there, attempt to construct the correct library path from the split. + String path = + ((BaseDexClassLoader) GeckoAppShell.class.getClassLoader()).findLibrary(libraryName); + if (path != null) { + return path; + } + + // SplitCompat is installed on the application context, so check there for library paths + // which were added to that ClassLoader. + final ClassLoader classLoader = GeckoAppShell.getApplicationContext().getClassLoader(); + if (classLoader instanceof BaseDexClassLoader) { + path = ((BaseDexClassLoader) classLoader).findLibrary(libraryName); + if (path != null) { + return path; + } + } + + throw new RuntimeException("Could not find mozglue path."); + } + + private static String getLibraryBase() { + final String mozglue = getLibraryPath("mozglue"); + final int lastSlash = mozglue.lastIndexOf('/'); + if (lastSlash < 0) { + throw new IllegalStateException("Invalid library path for libmozglue.so: " + mozglue); + } + final String base = mozglue.substring(0, lastSlash); + Log.i(LOGTAG, "Library base=" + base); + return base; + } + + private static void loadLibsSetupLocked(final Context context) { + putenv("GRE_HOME=" + getGREDir(context).getPath()); + putenv("MOZ_ANDROID_LIBDIR=" + getLibraryBase()); + } + + @RobocopTarget + public static synchronized void loadSQLiteLibs(final Context context) { + if (sSQLiteLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadSQLiteLibsNative(); + sSQLiteLibsLoaded = true; + } + + public static synchronized void loadNSSLibs(final Context context) { + if (sNSSLibsLoaded) { + return; + } + + loadMozGlue(context); + loadLibsSetupLocked(context); + loadNSSLibsNative(); + sNSSLibsLoaded = true; + } + + @SuppressWarnings("deprecation") + private static 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. + final File outDirFile = new File(outDir); + if (!outDirFile.isDirectory()) { + if (!outDirFile.mkdirs()) { + Log.e(LOGTAG, "Couldn't create " + outDir); + return false; + } + } + + if (Build.VERSION.SDK_INT >= 21) { + final String[] abis = Build.SUPPORTED_ABIS; + for (final 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( + final String lib, final String outDir, final String apkPath, final 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 (final 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 (final Exception e) { + Log.e(LOGTAG, "Failed to extract lib from APK.", e); + return false; + } + } + + private static boolean attemptLoad(final String path) { + try { + System.load(path); + return true; + } catch (final 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. + * + * <p>Returns null or the cause exception. + */ + public static Throwable doLoadLibrary(final Context context, final String lib) { + try { + // Attempt 1: the way that should work. + System.loadLibrary(lib); + return null; + } catch (final Throwable e) { + final String libPath = getLibraryPath(lib); + // Does it even exist? + if (new File(libPath).exists()) { + if (attemptLoad(libPath)) { + // Success! + return null; + } + throw new RuntimeException( + "Library exists but couldn't load." + "Path: " + libPath + " lib: " + lib, e); + } + throw new RuntimeException( + "Library doesn't exist when it should." + "Path: " + libPath + " lib: " + lib, e); + } + } + + public static synchronized void loadMozGlue(final Context context) { + if (sMozGlueLoaded) { + return; + } + + doLoadLibrary(context, "mozglue"); + sMozGlueLoaded = true; + } + + public static synchronized void loadGeckoLibs(final Context context) { + loadLibsSetupLocked(context); + loadGeckoLibsNative(); + } + + @SuppressWarnings("serial") + public static class AbortException extends Exception { + public AbortException(final 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, + int prefsFd, + int prefMapFd, + int ipcFd, + int crashFd, + int crashAnnotationFd, + boolean xpcshell, + String outFilePath); + + private static native void loadGeckoLibsNative(); + + private static native void loadSQLiteLibsNative(); + + private static native void loadNSSLibsNative(); + + public static native void suppressCrashDialog(); +} 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 0000000000..3b0f8cc96b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/JNIObject.java @@ -0,0 +1,20 @@ +/* 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; + +// Class that all classes with native methods extend from. +public abstract class JNIObject { + // Pointer that references the native object. This is volatile because it may be accessed + // by multiple threads simultaneously. + private volatile long mHandle; + + // Dispose of any reference to a native object. + // + // If the native instance is destroyed from the native side, this should never be + // called, so you should throw an UnsupportedOperationException. If instead you + // want to destroy the native side from the Java end, make override this with + // a native call, and the right thing will be done in the native code. + 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 0000000000..028cfd6590 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/NativeReference.java @@ -0,0 +1,12 @@ +/* -*- 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/SharedMemory.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java new file mode 100644 index 0000000000..76dd99ce10 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/mozglue/SharedMemory.java @@ -0,0 +1,190 @@ +/* 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.os.MemoryFile; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Parcelable; +import android.util.Log; +import java.io.FileDescriptor; +import java.io.IOException; +import java.lang.reflect.Method; + +public class SharedMemory implements Parcelable { + private static final String LOGTAG = "GeckoShmem"; + private static final Method sGetFDMethod; + private ParcelFileDescriptor mDescriptor; + private int mSize; + private int mId; + private long mHandle; // The native pointer. + private boolean mIsMapped; + private MemoryFile mBackedFile; + + // MemoryFile.getFileDescriptor() is hidden. :( + static { + Method method = null; + try { + method = MemoryFile.class.getDeclaredMethod("getFileDescriptor"); + } catch (final NoSuchMethodException e) { + e.printStackTrace(); + } + sGetFDMethod = method; + } + + private SharedMemory(final Parcel in) { + mDescriptor = in.readFileDescriptor(); + mSize = in.readInt(); + mId = in.readInt(); + } + + public static final Creator<SharedMemory> CREATOR = + new Creator<SharedMemory>() { + @Override + public SharedMemory createFromParcel(final Parcel in) { + return new SharedMemory(in); + } + + @Override + public SharedMemory[] newArray(final int size) { + return new SharedMemory[size]; + } + }; + + @Override + public int describeContents() { + return CONTENTS_FILE_DESCRIPTOR; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + // We don't want ParcelFileDescriptor.writeToParcel() to close the fd. + dest.writeFileDescriptor(mDescriptor.getFileDescriptor()); + dest.writeInt(mSize); + dest.writeInt(mId); + } + + public SharedMemory(final int id, final int size) throws NoSuchMethodException, IOException { + if (sGetFDMethod == null) { + throw new NoSuchMethodException("MemoryFile.getFileDescriptor() doesn't exist."); + } + mBackedFile = new MemoryFile(null, size); + try { + final FileDescriptor fd = (FileDescriptor) sGetFDMethod.invoke(mBackedFile); + mDescriptor = ParcelFileDescriptor.dup(fd); + mSize = size; + mId = id; + mBackedFile.allowPurging(false); + } catch (final Exception e) { + e.printStackTrace(); + close(); + throw new IOException(e.getMessage()); + } + } + + public void flush() { + if (!mIsMapped) { + return; + } + + unmap(mHandle, mSize); + mHandle = 0; + mIsMapped = false; + } + + public void close() { + flush(); + + if (mDescriptor != null) { + try { + mDescriptor.close(); + } catch (final IOException e) { + e.printStackTrace(); + } + mDescriptor = null; + } + } + + // Should only be called by process that allocates shared memory. + public void dispose() { + if (!isValid()) { + return; + } + + close(); + + if (mBackedFile != null) { + mBackedFile.close(); + mBackedFile = null; + } + } + + private native void unmap(long address, int size); + + public boolean isValid() { + return mDescriptor != null; + } + + public int getSize() { + return mSize; + } + + private int getFD() { + return isValid() ? mDescriptor.getFd() : -1; + } + + public long getPointer() { + if (!isValid()) { + return 0; + } + + if (!mIsMapped) { + try { + mHandle = map(getFD(), mSize); + } catch (final NullPointerException e) { + Log.e(LOGTAG, "SharedMemory#" + mId + " error.", e); + throw e; + } + if (mHandle != 0) { + mIsMapped = true; + } + } + return mHandle; + } + + private native long map(int fd, int size); + + @Override + protected void finalize() throws Throwable { + if (mBackedFile != null) { + Log.w(LOGTAG, "dispose() not called before finalizing"); + } + dispose(); + + super.finalize(); + } + + @Override + public String toString() { + return "SHM(" + + getSize() + + " bytes): id=" + + mId + + ", backing=" + + mBackedFile + + ",fd=" + + mDescriptor; + } + + @Override + public boolean equals(final Object that) { + return (this == that) || ((that instanceof SharedMemory) && (hashCode() == that.hashCode())); + } + + @Override + public int hashCode() { + return mId; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja new file mode 100644 index 0000000000..fa2f336566 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoChildProcessServices.jinja @@ -0,0 +1,19 @@ +/* -*- 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.process; + +public class GeckoChildProcessServices { + /* package */ static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = {{MOZ_ANDROID_CONTENT_SERVICE_COUNT}}; + public static final class gmplugin extends GeckoServiceChildProcess {} + public static final class socket extends GeckoServiceChildProcess {} + public static final class gpu extends GeckoServiceGpuProcess {} + public static final class utility extends GeckoServiceChildProcess {} + public static final class ipdlunittest extends GeckoServiceChildProcess {} + +{% for id in range(0, MOZ_ANDROID_CONTENT_SERVICE_COUNT | int) %} + public static final class tab{{ id }} extends GeckoServiceChildProcess {} +{% endfor %} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java new file mode 100644 index 0000000000..039396f9e8 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessManager.java @@ -0,0 +1,927 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.os.DeadObjectException; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.collection.ArrayMap; +import androidx.collection.ArraySet; +import androidx.collection.SimpleArrayMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoNetworkManager; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.GeckoThread.FileDescriptors; +import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.TelemetryUtils; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.CompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.gfx.RemoteSurfaceAllocator; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.process.ServiceAllocator.PriorityLevel; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.XPCOMEventTarget; +import org.mozilla.geckoview.GeckoResult; + +public final class GeckoProcessManager extends IProcessManager.Stub { + private static final String LOGTAG = "GeckoProcessManager"; + private static final GeckoProcessManager INSTANCE = new GeckoProcessManager(); + private static final int INVALID_PID = 0; + + // This id univocally identifies the current process manager instance + private final String mInstanceId; + + public static GeckoProcessManager getInstance() { + return INSTANCE; + } + + @WrapForJNI(calledFrom = "gecko") + private static void setEditableChildParent( + final IGeckoEditableChild child, final IGeckoEditableParent parent) { + try { + child.transferParent(parent); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Cannot set parent", e); + } + } + + @WrapForJNI(stubName = "GetEditableParent", dispatchTo = "gecko") + private static native void nativeGetEditableParent( + IGeckoEditableChild child, long contentId, long tabId); + + @Override // IProcessManager + public void getEditableParent( + final IGeckoEditableChild child, final long contentId, final long tabId) { + nativeGetEditableParent(child, contentId, tabId); + } + + /** + * Returns the surface allocator interface to be used by child processes to allocate Surfaces. The + * service bound to the returned interface may live in either the GPU process or parent process. + */ + @Override // IProcessManager + public ISurfaceAllocator getSurfaceAllocator() { + final GeckoResult<Boolean> gpuEnabled = GeckoAppShell.isGpuProcessEnabled(); + + try { + final GeckoResult<ISurfaceAllocator> allocator = new GeckoResult<>(); + if (gpuEnabled.poll(1000)) { + // The GPU process is enabled, so look it up and ask it for its surface allocator. + XPCOMEventTarget.runOnLauncherThread( + () -> { + final Selector selector = new Selector(GeckoProcessType.GPU); + final GpuProcessConnection conn = + (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector); + if (conn != null) { + allocator.complete(conn.getSurfaceAllocator()); + } else { + // If we cannot find a GPU process, it has probably been killed and not yet + // restarted. Return null here, and allow the caller to try again later. + // We definitely do *not* want to return the parent process allocator instead, as + // that will result in surfaces being allocated in the parent process, which + // therefore won't be usable when the GPU process is eventually launched. + allocator.complete(null); + } + }); + } else { + // The GPU process is disabled, so return the parent process allocator instance. + allocator.complete(RemoteSurfaceAllocator.getInstance(0)); + } + return allocator.poll(100); + } catch (final Throwable e) { + Log.e(LOGTAG, "Error in getSurfaceAllocator", e); + return null; + } + } + + @WrapForJNI + public static CompositorSurfaceManager getCompositorSurfaceManager() { + final Selector selector = new Selector(GeckoProcessType.GPU); + final GpuProcessConnection conn = + (GpuProcessConnection) INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return null; + } + return conn.getCompositorSurfaceManager(); + } + + /** Gecko uses this class to uniquely identify a process managed by GeckoProcessManager. */ + public static final class Selector { + private final GeckoProcessType mType; + private final int mPid; + + @WrapForJNI + private Selector(@NonNull final GeckoProcessType type, final int pid) { + if (pid == INVALID_PID) { + throw new RuntimeException("Invalid PID"); + } + + mType = type; + mPid = pid; + } + + @WrapForJNI + private Selector(@NonNull final GeckoProcessType type) { + mType = type; + mPid = INVALID_PID; + } + + public GeckoProcessType getType() { + return mType; + } + + public int getPid() { + return mPid; + } + + @Override + public boolean equals(final Object obj) { + if (obj == null) { + return false; + } + + if (obj == ((Object) this)) { + return true; + } + + final Selector other = (Selector) obj; + return mType == other.mType && mPid == other.mPid; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {mType, mPid}); + } + } + + private static final class IncompleteChildConnectionException extends RuntimeException { + public IncompleteChildConnectionException(@NonNull final String msg) { + super(msg); + } + } + + /** + * Maintains state pertaining to an individual child process. Inheriting from + * ServiceAllocator.InstanceInfo enables this class to work with ServiceAllocator. + */ + private static class ChildConnection extends ServiceAllocator.InstanceInfo { + private IChildProcess mChild; + private GeckoResult<IChildProcess> mPendingBind; + private int mPid; + + protected ChildConnection( + @NonNull final ServiceAllocator allocator, + @NonNull final GeckoProcessType type, + @NonNull final PriorityLevel initialPriority) { + super(allocator, type, initialPriority); + mPid = INVALID_PID; + } + + public int getPid() { + XPCOMEventTarget.assertOnLauncherThread(); + if (mChild == null) { + throw new IncompleteChildConnectionException( + "Calling ChildConnection.getPid() on an incomplete connection"); + } + + return mPid; + } + + private GeckoResult<IChildProcess> completeFailedBind( + @NonNull final ServiceAllocator.BindException e) { + XPCOMEventTarget.assertOnLauncherThread(); + Log.e(LOGTAG, "Failed bind", e); + + if (mPendingBind == null) { + throw new IllegalStateException("Bind failed with null mPendingBind"); + } + + final GeckoResult<IChildProcess> bindResult = mPendingBind; + mPendingBind = null; + unbind().accept(v -> bindResult.completeExceptionally(e)); + return bindResult; + } + + public GeckoResult<IChildProcess> bind() { + XPCOMEventTarget.assertOnLauncherThread(); + + if (mChild != null) { + // Already bound + return GeckoResult.fromValue(mChild); + } + + if (mPendingBind != null) { + // Bind in progress + return mPendingBind; + } + + mPendingBind = new GeckoResult<>(); + try { + if (!bindService()) { + throw new ServiceAllocator.BindException("Cannot connect to process"); + } + } catch (final ServiceAllocator.BindException e) { + return completeFailedBind(e); + } + + return mPendingBind; + } + + public GeckoResult<Void> unbind() { + XPCOMEventTarget.assertOnLauncherThread(); + + if (mPendingBind != null) { + // We called unbind() while bind() was still pending completion + return mPendingBind.then(child -> unbind()); + } + + if (mChild == null) { + // Not bound in the first place + return GeckoResult.fromValue(null); + } + + unbindService(); + + return GeckoResult.fromValue(null); + } + + @Override + protected void onBinderConnected(final IBinder service) { + XPCOMEventTarget.assertOnLauncherThread(); + + final IChildProcess child = IChildProcess.Stub.asInterface(service); + try { + mPid = child.getPid(); + onBinderConnected(child); + } catch (final DeadObjectException e) { + unbindService(); + + // mPendingBind might be null if a bind was initiated by the system (eg Service Restart) + if (mPendingBind != null) { + mPendingBind.completeExceptionally(e); + mPendingBind = null; + } + + return; + } catch (final RemoteException e) { + throw new RuntimeException(e); + } + + mChild = child; + GeckoProcessManager.INSTANCE.mConnections.onBindComplete(this); + + // mPendingBind might be null if a bind was initiated by the system (eg Service Restart) + if (mPendingBind != null) { + mPendingBind.complete(mChild); + mPendingBind = null; + } + } + + // Subclasses of ChildConnection can override this method to make any IChildProcess calls + // specific to their process type immediately after connection. + protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException {} + + @Override + protected void onReleaseResources() { + XPCOMEventTarget.assertOnLauncherThread(); + + // NB: This must happen *before* resetting mPid! + GeckoProcessManager.INSTANCE.mConnections.removeConnection(this); + + mChild = null; + mPid = INVALID_PID; + } + } + + private static class NonContentConnection extends ChildConnection { + public NonContentConnection( + @NonNull final ServiceAllocator allocator, @NonNull final GeckoProcessType type) { + super(allocator, type, PriorityLevel.FOREGROUND); + if (type == GeckoProcessType.CONTENT) { + throw new AssertionError("Attempt to create a NonContentConnection as CONTENT"); + } + } + + protected void onAppForeground() { + setPriorityLevel(PriorityLevel.FOREGROUND); + } + + protected void onAppBackground() { + setPriorityLevel(PriorityLevel.IDLE); + } + } + + private static final class GpuProcessConnection extends NonContentConnection { + private CompositorSurfaceManager mCompositorSurfaceManager; + private ISurfaceAllocator mSurfaceAllocator; + + // Unique ID used to identify each GPU process instance. Will always be non-zero, + // and unlike the process' pid cannot be the same value for successive instances. + private int mUniqueGpuProcessId; + // Static counter used to initialize each instance's mUniqueGpuProcessId + private static int sUniqueGpuProcessIdCounter = 0; + + public GpuProcessConnection(@NonNull final ServiceAllocator allocator) { + super(allocator, GeckoProcessType.GPU); + + // Initialize the unique ID ensuring we skip 0 (as that is reserved for parent process + // allocators). + if (sUniqueGpuProcessIdCounter == 0) { + sUniqueGpuProcessIdCounter++; + } + mUniqueGpuProcessId = sUniqueGpuProcessIdCounter++; + } + + @Override + protected void onBinderConnected(@NonNull final IChildProcess child) throws RemoteException { + mCompositorSurfaceManager = new CompositorSurfaceManager(child.getCompositorSurfaceManager()); + mSurfaceAllocator = child.getSurfaceAllocator(mUniqueGpuProcessId); + } + + public CompositorSurfaceManager getCompositorSurfaceManager() { + return mCompositorSurfaceManager; + } + + public ISurfaceAllocator getSurfaceAllocator() { + return mSurfaceAllocator; + } + } + + private static final class SocketProcessConnection extends NonContentConnection { + private boolean mIsForeground = true; + private boolean mIsNetworkUp = true; + + public SocketProcessConnection(@NonNull final ServiceAllocator allocator) { + super(allocator, GeckoProcessType.SOCKET); + GeckoProcessManager.INSTANCE.mConnections.enableNetworkNotifications(); + } + + public void onNetworkStateChange(final boolean isNetworkUp) { + mIsNetworkUp = isNetworkUp; + prioritize(); + } + + @Override + protected void onAppForeground() { + mIsForeground = true; + prioritize(); + } + + @Override + protected void onAppBackground() { + mIsForeground = false; + prioritize(); + } + + private static final PriorityLevel[][] sPriorityStates = initPriorityStates(); + + private static PriorityLevel[][] initPriorityStates() { + final PriorityLevel[][] states = new PriorityLevel[2][2]; + // Background, no network + states[0][0] = PriorityLevel.IDLE; + // Background, network + states[0][1] = PriorityLevel.BACKGROUND; + // Foreground, no network + states[1][0] = PriorityLevel.IDLE; + // Foreground, network + states[1][1] = PriorityLevel.FOREGROUND; + return states; + } + + private void prioritize() { + final PriorityLevel nextPriority = + sPriorityStates[mIsForeground ? 1 : 0][mIsNetworkUp ? 1 : 0]; + setPriorityLevel(nextPriority); + } + } + + private static final class ContentConnection extends ChildConnection { + private static final String TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME = + "GV_CONTENT_PROCESS_LIFETIME_MS"; + + private TelemetryUtils.UptimeTimer mLifetimeTimer = null; + + public ContentConnection( + @NonNull final ServiceAllocator allocator, @NonNull final PriorityLevel initialPriority) { + super(allocator, GeckoProcessType.CONTENT, initialPriority); + } + + @Override + protected void onBinderConnected(final IBinder service) { + mLifetimeTimer = new TelemetryUtils.UptimeTimer(TELEMETRY_PROCESS_LIFETIME_HISTOGRAM_NAME); + super.onBinderConnected(service); + } + + @Override + protected void onReleaseResources() { + if (mLifetimeTimer != null) { + mLifetimeTimer.stop(); + mLifetimeTimer = null; + } + + super.onReleaseResources(); + } + } + + /** This class manages the state surrounding existing connections and their priorities. */ + private static final class ConnectionManager extends JNIObject { + // Connections to non-content processes + private final ArrayMap<GeckoProcessType, NonContentConnection> mNonContentConnections; + // Mapping of pid to content process + private final SimpleArrayMap<Integer, ContentConnection> mContentPids; + // Set of initialized content process connections + private final ArraySet<ContentConnection> mContentConnections; + // Set of bound but uninitialized content connections + private final ArraySet<ContentConnection> mNonStartedContentConnections; + // Allocator for service IDs + private final ServiceAllocator mServiceAllocator; + private boolean mIsObservingNetwork = false; + + public ConnectionManager() { + mNonContentConnections = new ArrayMap<GeckoProcessType, NonContentConnection>(); + mContentPids = new SimpleArrayMap<Integer, ContentConnection>(); + mContentConnections = new ArraySet<ContentConnection>(); + mNonStartedContentConnections = new ArraySet<ContentConnection>(); + mServiceAllocator = new ServiceAllocator(); + + // Attach to native once JNI is ready. + if (GeckoThread.isStateAtLeast(GeckoThread.State.JNI_READY)) { + attachTo(this); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.JNI_READY, ConnectionManager.class, "attachTo", this); + } + } + + private void enableNetworkNotifications() { + if (mIsObservingNetwork) { + return; + } + + mIsObservingNetwork = true; + + // Ensure that GeckoNetworkManager is monitoring network events so that we can + // prioritize the socket process. + ThreadUtils.runOnUiThread( + () -> { + GeckoNetworkManager.getInstance().enableNotifications(); + }); + + observeNetworkNotifications(); + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void attachTo(ConnectionManager instance); + + @WrapForJNI(dispatchTo = "gecko") + private native void observeNetworkNotifications(); + + @WrapForJNI(calledFrom = "gecko") + private void onBackground() { + XPCOMEventTarget.runOnLauncherThread(() -> onAppBackgroundInternal()); + } + + @WrapForJNI(calledFrom = "gecko") + private void onForeground() { + XPCOMEventTarget.runOnLauncherThread(() -> onAppForegroundInternal()); + } + + @WrapForJNI(calledFrom = "gecko") + private void onNetworkStateChange(final boolean isUp) { + XPCOMEventTarget.runOnLauncherThread(() -> onNetworkStateChangeInternal(isUp)); + } + + @Override + protected native void disposeNative(); + + private void onAppBackgroundInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + + for (final NonContentConnection conn : mNonContentConnections.values()) { + conn.onAppBackground(); + } + } + + private void onAppForegroundInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + + for (final NonContentConnection conn : mNonContentConnections.values()) { + conn.onAppForeground(); + } + } + + private void onNetworkStateChangeInternal(final boolean isUp) { + XPCOMEventTarget.assertOnLauncherThread(); + + final SocketProcessConnection conn = + (SocketProcessConnection) mNonContentConnections.get(GeckoProcessType.SOCKET); + if (conn == null) { + return; + } + + conn.onNetworkStateChange(isUp); + } + + private void removeContentConnection(@NonNull final ChildConnection conn) { + if (!mContentConnections.remove(conn)) { + throw new RuntimeException("Attempt to remove non-registered connection"); + } + mNonStartedContentConnections.remove(conn); + + final int pid; + + try { + pid = conn.getPid(); + } catch (final IncompleteChildConnectionException e) { + // conn lost its binding before it was able to retrieve its pid. It follows that + // mContentPids does not have an entry for this connection, so we can just return. + return; + } + + if (pid == INVALID_PID) { + return; + } + + final ChildConnection removed = mContentPids.remove(Integer.valueOf(pid)); + if (removed != null && removed != conn) { + throw new RuntimeException( + "Integrity error - connection mismatch for pid " + Integer.toString(pid)); + } + } + + public void removeConnection(@NonNull final ChildConnection conn) { + XPCOMEventTarget.assertOnLauncherThread(); + + if (conn.getType() == GeckoProcessType.CONTENT) { + removeContentConnection(conn); + return; + } + + final ChildConnection removed = mNonContentConnections.remove(conn.getType()); + if (removed != conn) { + throw new RuntimeException( + "Integrity error - connection mismatch for process type " + conn.getType().toString()); + } + } + + /** Saves any state information that was acquired upon start completion. */ + public void onBindComplete(@NonNull final ChildConnection conn) { + if (conn.getType() == GeckoProcessType.CONTENT) { + final int pid = conn.getPid(); + if (pid == INVALID_PID) { + throw new AssertionError( + "PID is invalid even though our caller just successfully retrieved it after binding"); + } + + mContentPids.put(pid, (ContentConnection) conn); + } + } + + /** Retrieve the ChildConnection for an already running content process. */ + private ContentConnection getExistingContentConnection(@NonNull final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + if (selector.getType() != GeckoProcessType.CONTENT) { + throw new IllegalArgumentException("Selector is not for content!"); + } + + return mContentPids.get(selector.getPid()); + } + + /** Unconditionally create a new content connection for the specified priority. */ + private ContentConnection getNewContentConnection(@NonNull final PriorityLevel newPriority) { + final ContentConnection result = new ContentConnection(mServiceAllocator, newPriority); + mContentConnections.add(result); + + return result; + } + + /** Retrieve the ChildConnection for an already running child process of any type. */ + public ChildConnection getExistingConnection(@NonNull final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + + final GeckoProcessType type = selector.getType(); + + if (type == GeckoProcessType.CONTENT) { + return getExistingContentConnection(selector); + } + + return mNonContentConnections.get(type); + } + + /** + * Retrieve a ChildConnection for a content process for the purposes of starting. If there are + * any preloaded content processes already running, we will use one of those. Otherwise we will + * allocate a new ChildConnection. + */ + private ChildConnection getContentConnectionForStart() { + XPCOMEventTarget.assertOnLauncherThread(); + + if (mNonStartedContentConnections.isEmpty()) { + return getNewContentConnection(PriorityLevel.FOREGROUND); + } + + final ChildConnection conn = + mNonStartedContentConnections.removeAt(mNonStartedContentConnections.size() - 1); + conn.setPriorityLevel(PriorityLevel.FOREGROUND); + return conn; + } + + /** Retrieve or create a new child process for the specified non-content process. */ + private ChildConnection getNonContentConnection(@NonNull final GeckoProcessType type) { + XPCOMEventTarget.assertOnLauncherThread(); + if (type == GeckoProcessType.CONTENT) { + throw new IllegalArgumentException("Content processes not supported by this method"); + } + + NonContentConnection connection = mNonContentConnections.get(type); + if (connection == null) { + if (type == GeckoProcessType.SOCKET) { + connection = new SocketProcessConnection(mServiceAllocator); + } else if (type == GeckoProcessType.GPU) { + connection = new GpuProcessConnection(mServiceAllocator); + } else { + connection = new NonContentConnection(mServiceAllocator, type); + } + + mNonContentConnections.put(type, connection); + } + + return connection; + } + + /** Retrieve a ChildConnection for the purposes of starting a new child process. */ + public ChildConnection getConnectionForStart(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + return getContentConnectionForStart(); + } + + return getNonContentConnection(type); + } + + /** Retrieve a ChildConnection for the purposes of preloading a new child process. */ + public ChildConnection getConnectionForPreload(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + final ContentConnection conn = getNewContentConnection(PriorityLevel.BACKGROUND); + mNonStartedContentConnections.add(conn); + return conn; + } + + return getNonContentConnection(type); + } + } + + private final ConnectionManager mConnections; + + private GeckoProcessManager() { + mConnections = new ConnectionManager(); + mInstanceId = UUID.randomUUID().toString(); + } + + public void preload(final GeckoProcessType... types) { + XPCOMEventTarget.launcherThread() + .execute( + () -> { + for (final GeckoProcessType type : types) { + final ChildConnection connection = mConnections.getConnectionForPreload(type); + connection.bind(); + } + }); + } + + public void crashChild(@NonNull final Selector selector) { + XPCOMEventTarget.launcherThread() + .execute( + () -> { + final ChildConnection conn = mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.bind() + .accept( + proc -> { + try { + proc.crash(); + } catch (final RemoteException e) { + } + }); + }); + } + + @WrapForJNI + private static void shutdownProcess(final Selector selector) { + XPCOMEventTarget.assertOnLauncherThread(); + final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.unbind(); + } + + @WrapForJNI + private static void setProcessPriority( + @NonNull final Selector selector, + @NonNull final PriorityLevel priorityLevel, + final int relativeImportance) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + final ChildConnection conn = INSTANCE.mConnections.getExistingConnection(selector); + if (conn == null) { + return; + } + + conn.setPriorityLevel(priorityLevel, relativeImportance); + }); + } + + @WrapForJNI + private static GeckoResult<Integer> start( + final GeckoProcessType type, + final String[] args, + final int prefsFd, + final int prefMapFd, + final int ipcFd, + final int crashFd, + final int crashAnnotationFd) { + final GeckoResult<Integer> result = new GeckoResult<>(); + final StartInfo info = + new StartInfo( + type, + GeckoThread.InitInfo.builder() + .args(args) + .userSerialNumber(System.getenv("MOZ_ANDROID_USER_SERIAL_NUMBER")) + .extras(GeckoThread.getActiveExtras()) + .flags(filterFlagsForChild(GeckoThread.getActiveFlags())) + .fds( + FileDescriptors.builder() + .prefs(prefsFd) + .prefMap(prefMapFd) + .ipc(ipcFd) + .crashReporter(crashFd) + .crashAnnotation(crashAnnotationFd) + .build()) + .build()); + + XPCOMEventTarget.runOnLauncherThread( + () -> { + INSTANCE + .start(info) + .accept(result::complete, result::completeExceptionally) + .finally_(info.pfds::close); + }); + + return result; + } + + private static int filterFlagsForChild(final int flags) { + return flags & GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER; + } + + private static class StartInfo { + final GeckoProcessType type; + final String crashHandler; + final GeckoThread.InitInfo init; + + final ParcelFileDescriptors pfds; + + private StartInfo(final GeckoProcessType type, final GeckoThread.InitInfo initInfo) { + this.type = type; + this.init = initInfo; + crashHandler = + GeckoAppShell.getCrashHandlerService() != null + ? GeckoAppShell.getCrashHandlerService().getName() + : null; + // The native side owns the File Descriptors so we cannot call adopt here. + pfds = ParcelFileDescriptors.from(initInfo.fds); + } + } + + private static final int MAX_RETRIES = 3; + + private GeckoResult<Integer> start(final StartInfo info) { + return start(info, new ArrayList<>()); + } + + private GeckoResult<Integer> retry( + final StartInfo info, final List<Throwable> retryLog, final Throwable error) { + retryLog.add(error); + + if (error instanceof StartException) { + final StartException startError = (StartException) error; + if (startError.errorCode == IChildProcess.STARTED_BUSY) { + // This process is owned by a different runtime, so we can't use + // it. We will keep retrying indefinitely until we find a non-busy process. + // Note: this strategy is pretty bad, we go through each process in + // sequence until one works, the multiple runtime case is test-only + // for now, so that's ok. We can improve on this if we eventually + // end up needing something fancier. + return start(info, retryLog); + } + } + + // If we couldn't unbind there's something very wrong going on and we bail + // immediately. + if (retryLog.size() >= MAX_RETRIES || error instanceof UnbindException) { + return GeckoResult.fromException(fromRetryLog(retryLog)); + } + + return start(info, retryLog); + } + + private String serializeLog(final List<Throwable> retryLog) { + if (retryLog == null || retryLog.size() == 0) { + return "Empty log."; + } + + final StringBuilder message = new StringBuilder(); + + for (final Throwable error : retryLog) { + if (error instanceof UnbindException) { + message.append("Could not unbind: "); + } else if (error instanceof StartException) { + message.append("Cannot restart child: "); + } else { + message.append("Error while binding: "); + } + message.append(error); + message.append(";"); + } + + return message.toString(); + } + + private RuntimeException fromRetryLog(final List<Throwable> retryLog) { + return new RuntimeException(serializeLog(retryLog), retryLog.get(retryLog.size() - 1)); + } + + private GeckoResult<Integer> start(final StartInfo info, final List<Throwable> retryLog) { + return startInternal(info).then(GeckoResult::fromValue, error -> retry(info, retryLog, error)); + } + + private static class StartException extends RuntimeException { + public final int errorCode; + + public StartException(final int errorCode, final int pid) { + super("Could not start process, errorCode: " + errorCode + " PID: " + pid); + this.errorCode = errorCode; + } + } + + private GeckoResult<Integer> startInternal(final StartInfo info) { + XPCOMEventTarget.assertOnLauncherThread(); + + final ChildConnection connection = mConnections.getConnectionForStart(info.type); + return connection + .bind() + .map( + child -> { + final int result = + child.start( + this, + mInstanceId, + info.init.args, + info.init.extras, + info.init.flags, + info.init.userSerialNumber, + info.crashHandler, + info.pfds.prefs, + info.pfds.prefMap, + info.pfds.ipc, + info.pfds.crashReporter, + info.pfds.crashAnnotation); + if (result == IChildProcess.STARTED_OK) { + return connection.getPid(); + } else { + throw new StartException(result, connection.getPid()); + } + }) + .then(GeckoResult::fromValue, error -> handleBindError(connection, error)); + } + + private GeckoResult<Integer> handleBindError( + final ChildConnection connection, final Throwable error) { + return connection + .unbind() + .then( + unused -> GeckoResult.fromException(error), + unbindError -> GeckoResult.fromException(new UnbindException(unbindError))); + } + + private static class UnbindException extends RuntimeException { + public UnbindException(final Throwable cause) { + super(cause); + } + } +} // GeckoProcessManager diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java new file mode 100644 index 0000000000..812a27614c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoProcessType.java @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import org.mozilla.gecko.annotation.WrapForJNI; + +@WrapForJNI +public enum GeckoProcessType { + // These need to match the stringified names from the GeckoProcessType enum + PARENT("default"), + PLUGIN("plugin"), + CONTENT("tab"), + IPDLUNITTEST("ipdlunittest"), + GMPLUGIN("gmplugin"), + GPU("gpu"), + VR("vr"), + RDD("rdd"), + SOCKET("socket"), + REMOTESANDBOXBROKER("sandboxbroker"), + FORKSERVER("forkserver"), + UTILITY("utility"); + + private final String mGeckoName; + + private GeckoProcessType(final String geckoName) { + mGeckoName = geckoName; + } + + @Override + public String toString() { + return mGeckoName; + } + + @WrapForJNI + private static final GeckoProcessType fromInt(final int type) { + return values()[type]; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java new file mode 100644 index 0000000000..e030a47c74 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceChildProcess.java @@ -0,0 +1,213 @@ +/* -*- 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.process; + +import android.app.Service; +import android.content.Intent; +import android.os.Binder; +import android.os.Bundle; +import android.os.IBinder; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.RemoteException; +import android.util.Log; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.GeckoThread.FileDescriptors; +import org.mozilla.gecko.GeckoThread.ParcelFileDescriptors; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.ICompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.util.ThreadUtils; + +public class GeckoServiceChildProcess extends Service { + private static final String LOGTAG = "ServiceChildProcess"; + + private static IProcessManager sProcessManager; + private static String sOwnerProcessId; + private final MemoryController mMemoryController = new MemoryController(); + + // Makes sure we don't reuse this process + private static boolean sCreateCalled; + + @WrapForJNI(calledFrom = "gecko") + private static void getEditableParent( + final IGeckoEditableChild child, final long contentId, final long tabId) { + try { + sProcessManager.getEditableParent(child, contentId, tabId); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Cannot get editable", e); + } + } + + @Override + public void onCreate() { + super.onCreate(); + Log.i(LOGTAG, "onCreate"); + + if (sCreateCalled) { + // We don't support reusing processes, and this could get us in a really weird state, + // so let's throw here. + throw new RuntimeException("Cannot reuse process."); + } + sCreateCalled = true; + + GeckoAppShell.setApplicationContext(getApplicationContext()); + GeckoThread.launch(); // Preload Gecko. + } + + protected static class ChildProcessBinder extends IChildProcess.Stub { + @Override + public int getPid() { + return Process.myPid(); + } + + @Override + public int start( + final IProcessManager procMan, + final String mainProcessId, + final String[] args, + final Bundle extras, + final int flags, + final String userSerialNumber, + final String crashHandlerService, + final ParcelFileDescriptor prefsPfd, + final ParcelFileDescriptor prefMapPfd, + final ParcelFileDescriptor ipcPfd, + final ParcelFileDescriptor crashReporterPfd, + final ParcelFileDescriptor crashAnnotationPfd) { + + final ParcelFileDescriptors pfds = + ParcelFileDescriptors.builder() + .prefs(prefsPfd) + .prefMap(prefMapPfd) + .ipc(ipcPfd) + .crashReporter(crashReporterPfd) + .crashAnnotation(crashAnnotationPfd) + .build(); + + synchronized (GeckoServiceChildProcess.class) { + if (sOwnerProcessId != null && !sOwnerProcessId.equals(mainProcessId)) { + Log.w( + LOGTAG, + "This process belongs to a different GeckoRuntime owner: " + + sOwnerProcessId + + " process: " + + mainProcessId); + // We need to close the File Descriptors here otherwise we will leak them causing a + // shutdown hang. + pfds.close(); + return IChildProcess.STARTED_BUSY; + } + if (sProcessManager != null) { + Log.e(LOGTAG, "Child process already started"); + pfds.close(); + return IChildProcess.STARTED_FAIL; + } + sProcessManager = procMan; + sOwnerProcessId = mainProcessId; + } + + final FileDescriptors fds = pfds.detach(); + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + if (crashHandlerService != null) { + try { + @SuppressWarnings("unchecked") + final Class<? extends Service> crashHandler = + (Class<? extends Service>) Class.forName(crashHandlerService); + + // Native crashes are reported through pipes, so we don't have to + // do anything special for that. + GeckoAppShell.setCrashHandlerService(crashHandler); + GeckoAppShell.ensureCrashHandling(crashHandler); + } catch (final ClassNotFoundException e) { + Log.w(LOGTAG, "Couldn't find crash handler service " + crashHandlerService); + } + } + + final GeckoThread.InitInfo info = + GeckoThread.InitInfo.builder() + .args(args) + .extras(extras) + .flags(flags) + .userSerialNumber(userSerialNumber) + .fds(fds) + .build(); + + if (GeckoThread.init(info)) { + GeckoThread.launch(); + } + } + }); + return IChildProcess.STARTED_OK; + } + + @Override + public void crash() { + GeckoThread.crash(); + } + + @Override + public ICompositorSurfaceManager getCompositorSurfaceManager() { + Log.e( + LOGTAG, "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process"); + throw new AssertionError( + "Invalid call to IChildProcess.getCompositorSurfaceManager for non-GPU process."); + } + + @Override + public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) { + Log.e(LOGTAG, "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process"); + throw new AssertionError( + "Invalid call to IChildProcess.getSurfaceAllocator for non-GPU process."); + } + } + + protected Binder createBinder() { + return new ChildProcessBinder(); + } + + private final Binder mBinder = createBinder(); + + @Override + public void onDestroy() { + Log.i(LOGTAG, "Destroying GeckoServiceChildProcess"); + System.exit(0); + } + + @Override + public IBinder onBind(final Intent intent) { + // Calling stopSelf ensures that whenever the client unbinds the process dies immediately. + stopSelf(); + return mBinder; + } + + @Override + public void onTrimMemory(final int level) { + mMemoryController.onTrimMemory(level); + + // This is currently a no-op in Service, but let's future-proof. + super.onTrimMemory(level); + } + + @Override + public void onLowMemory() { + mMemoryController.onLowMemory(); + super.onLowMemory(); + } + + /** + * Returns the surface allocator interface that should be used by this process to allocate + * Surfaces, for consumption in either the GPU process or parent process. + */ + public static ISurfaceAllocator getSurfaceAllocator() throws RemoteException { + return sProcessManager.getSurfaceAllocator(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java new file mode 100644 index 0000000000..e4312c7e67 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/GeckoServiceGpuProcess.java @@ -0,0 +1,63 @@ +/* -*- 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.process; + +import android.os.Binder; +import android.util.SparseArray; +import android.view.Surface; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.gfx.ICompositorSurfaceManager; +import org.mozilla.gecko.gfx.ISurfaceAllocator; +import org.mozilla.gecko.gfx.RemoteSurfaceAllocator; + +public class GeckoServiceGpuProcess extends GeckoServiceChildProcess { + private static final String LOGTAG = "ServiceGpuProcess"; + + private static final class GpuProcessBinder extends GeckoServiceChildProcess.ChildProcessBinder { + @Override + public ICompositorSurfaceManager getCompositorSurfaceManager() { + return RemoteCompositorSurfaceManager.getInstance(); + } + + @Override + public ISurfaceAllocator getSurfaceAllocator(final int allocatorId) { + return RemoteSurfaceAllocator.getInstance(allocatorId); + } + } + + @Override + protected Binder createBinder() { + return new GpuProcessBinder(); + } + + public static final class RemoteCompositorSurfaceManager extends ICompositorSurfaceManager.Stub { + private static RemoteCompositorSurfaceManager mInstance; + + @WrapForJNI + private static synchronized RemoteCompositorSurfaceManager getInstance() { + if (mInstance == null) { + mInstance = new RemoteCompositorSurfaceManager(); + } + return mInstance; + } + + private final SparseArray<Surface> mSurfaces = new SparseArray<Surface>(); + + @Override + public synchronized void onSurfaceChanged(final int widgetId, final Surface surface) { + if (surface != null) { + mSurfaces.put(widgetId, surface); + } else { + mSurfaces.remove(widgetId); + } + } + + @WrapForJNI + public synchronized Surface getCompositorSurface(final int widgetId) { + return mSurfaces.get(widgetId); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java new file mode 100644 index 0000000000..f2dcb7a52b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/MemoryController.java @@ -0,0 +1,74 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.content.ComponentCallbacks2; +import android.content.res.Configuration; +import android.util.Log; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoAppShell; + +public class MemoryController implements ComponentCallbacks2 { + private static final String LOGTAG = "MemoryController"; + private long mLastLowMemoryNotificationTime = 0; + + // Allowed elapsed time between full GCs while under constant memory pressure + private static final long LOW_MEMORY_ONGOING_RESET_TIME_MS = 10000; + + private static final int LOW = 0; + private static final int MODERATE = 1; + private static final int CRITICAL = 2; + + private int memoryLevelFromTrim(final int level) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE + || level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL) { + return CRITICAL; + } else if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { + return MODERATE; + } + return LOW; + } + + public void onTrimMemory(final int level) { + Log.i(LOGTAG, "onTrimMemory(" + level + ")"); + onMemoryNotification(memoryLevelFromTrim(level)); + } + + @Override + public void onConfigurationChanged(final @NonNull Configuration newConfig) {} + + public void onLowMemory() { + Log.i(LOGTAG, "onLowMemory"); + onMemoryNotification(CRITICAL); + } + + private void onMemoryNotification(final int level) { + if (level == LOW) { + // The trim level is too low to be actionable + return; + } + + // See nsIMemory.idl for descriptions of the various arguments to the "memory-pressure" + // observer. + final String observerArg; + + final long currentNotificationTime = System.currentTimeMillis(); + if (level == CRITICAL + || (currentNotificationTime - mLastLowMemoryNotificationTime) + >= LOW_MEMORY_ONGOING_RESET_TIME_MS) { + // We do a full "low-memory" notification for both new and last-ditch onTrimMemory requests. + observerArg = "low-memory"; + mLastLowMemoryNotificationTime = currentNotificationTime; + } else { + // If it has been less than ten seconds since the last time we sent a "low-memory" + // notification, we send a "low-memory-ongoing" notification instead. + // This prevents Gecko from re-doing full GC's repeatedly over and over in succession, + // as they are expensive and quickly result in diminishing returns. + observerArg = "low-memory-ongoing"; + } + + GeckoAppShell.notifyObservers("memory-pressure", observerArg); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java new file mode 100644 index 0000000000..8058d71601 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceAllocator.java @@ -0,0 +1,613 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.annotation.TargetApi; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.NonNull; +import java.security.SecureRandom; +import java.util.BitSet; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map.Entry; +import java.util.Set; +import java.util.UUID; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.XPCOMEventTarget; + +/* package */ final class ServiceAllocator { + private static final String LOGTAG = "ServiceAllocator"; + private static final int MAX_NUM_ISOLATED_CONTENT_SERVICES = + GeckoChildProcessServices.MAX_NUM_ISOLATED_CONTENT_SERVICES; + + private static boolean hasQApis() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + /** + * Possible priority levels that are available to child services. Each one maps to a flag that is + * passed into Context.bindService(). + */ + @WrapForJNI + public static enum PriorityLevel { + FOREGROUND(Context.BIND_IMPORTANT), + BACKGROUND(0), + IDLE(Context.BIND_WAIVE_PRIORITY); + + private final int mAndroidFlag; + + private PriorityLevel(final int androidFlag) { + mAndroidFlag = androidFlag; + } + + public int getAndroidFlag() { + return mAndroidFlag; + } + } + + public static final class BindException extends RuntimeException { + public BindException(@NonNull final String msg) { + super(msg); + } + } + + private interface BindServiceDelegate { + boolean bindService(ServiceConnection binding, PriorityLevel priority); + + String getServiceName(); + } + + /** + * Abstract class that holds the essential per-service data that is required to work with + * ServiceAllocator. ServiceAllocator clients should extend this class when implementing their + * per-service connection objects. + */ + public abstract static class InstanceInfo { + private class Binding implements ServiceConnection { + /** + * This implementation of ServiceConnection.onServiceConnected simply bounces the connection + * notification over to the launcher thread (if it is not already on it). + */ + @Override + public final void onServiceConnected(final ComponentName name, final IBinder service) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + onBinderConnectedInternal(service); + }); + } + + /** + * This implementation of ServiceConnection.onServiceDisconnected simply bounces the + * disconnection notification over to the launcher thread (if it is not already on it). + */ + @Override + public final void onServiceDisconnected(final ComponentName name) { + XPCOMEventTarget.runOnLauncherThread( + () -> { + onBinderConnectionLostInternal(); + }); + } + } + + private class DefaultBindDelegate implements BindServiceDelegate { + @Override + public boolean bindService( + @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent intent = new Intent(); + intent.setClassName(context, getServiceName()); + return bindServiceDefault(context, intent, binding, getAndroidFlags(priority)); + } + + @Override + public String getServiceName() { + return getSvcClassNameDefault(InstanceInfo.this); + } + } + + private class IsolatedBindDelegate implements BindServiceDelegate { + @Override + public boolean bindService( + @NonNull final ServiceConnection binding, @NonNull final PriorityLevel priority) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent intent = new Intent(); + intent.setClassName(context, getServiceName()); + return bindServiceIsolated( + context, intent, getAndroidFlags(priority), getIdInternal(), binding); + } + + @Override + public String getServiceName() { + return ServiceUtils.buildIsolatedSvcName(getType()); + } + } + + private final ServiceAllocator mAllocator; + private final GeckoProcessType mType; + private final String mId; + private final EnumMap<PriorityLevel, Binding> mBindings; + private final BindServiceDelegate mBindDelegate; + + private boolean mCalledConnected = false; + private boolean mCalledConnectionLost = false; + private boolean mIsDefunct = false; + + private PriorityLevel mCurrentPriority; + private int mRelativeImportance = 0; + + protected InstanceInfo( + @NonNull final ServiceAllocator allocator, + @NonNull final GeckoProcessType type, + @NonNull final PriorityLevel initialPriority) { + mAllocator = allocator; + mType = type; + mId = mAllocator.allocate(type); + mBindings = new EnumMap<PriorityLevel, Binding>(PriorityLevel.class); + mBindDelegate = getBindServiceDelegate(); + + mCurrentPriority = initialPriority; + } + + private BindServiceDelegate getBindServiceDelegate() { + if (mType != GeckoProcessType.CONTENT) { + // Non-content services just use default binding + return this.new DefaultBindDelegate(); + } + + // Content services defer to the alloc policy + return mAllocator.mContentAllocPolicy.getBindServiceDelegate(this); + } + + public PriorityLevel getPriorityLevel() { + XPCOMEventTarget.assertOnLauncherThread(); + return mCurrentPriority; + } + + public boolean setPriorityLevel(@NonNull final PriorityLevel newPriority) { + return setPriorityLevel(newPriority, 0); + } + + public boolean setPriorityLevel( + @NonNull final PriorityLevel newPriority, final int relativeImportance) { + XPCOMEventTarget.assertOnLauncherThread(); + mCurrentPriority = newPriority; + mRelativeImportance = relativeImportance; + + // If we haven't bound yet then we can just return + if (mBindings.size() == 0) { + return true; + } + + // Otherwise we need to update our bindings + return updateBindings(); + } + + /** + * Only content services have unique IDs. This method throws if called for a non-content service + * type. + */ + public String getId() { + if (mId == null) { + throw new RuntimeException("This service does not have a unique id"); + } + + return mId; + } + + /** This method is infallible and returns an empty string for non-content services. */ + private String getIdInternal() { + return mId == null ? "" : mId; + } + + public boolean isContent() { + return mType == GeckoProcessType.CONTENT; + } + + public GeckoProcessType getType() { + return mType; + } + + protected boolean bindService() { + if (mIsDefunct) { + final String errorMsg = + "Attempt to bind a defunct InstanceInfo for " + mType + " child process"; + throw new BindException(errorMsg); + } + + return updateBindings(); + } + + /** + * Unbinds the service described by |this| and releases our unique ID. This method may safely be + * called multiple times even if we are already defunct. + */ + protected void unbindService() { + XPCOMEventTarget.assertOnLauncherThread(); + + // This could happen if a service death races with our attempt to shut it down. + if (mIsDefunct) { + return; + } + + final Context context = GeckoAppShell.getApplicationContext(); + + // Make a clone of mBindings to iterate over since we're going to mutate the original + final EnumMap<PriorityLevel, Binding> cloned = mBindings.clone(); + for (final Entry<PriorityLevel, Binding> entry : cloned.entrySet()) { + try { + context.unbindService(entry.getValue()); + } catch (final IllegalArgumentException e) { + // The binding was already dead. That's okay. + } + + mBindings.remove(entry.getKey()); + } + + if (mBindings.size() != 0) { + throw new IllegalStateException("Unable to release all bindings"); + } + + mIsDefunct = true; + mAllocator.release(this); + onReleaseResources(); + } + + private void onBinderConnectedInternal(@NonNull final IBinder service) { + XPCOMEventTarget.assertOnLauncherThread(); + // We only care about the first time this is called; subsequent bindings can be ignored. + if (mCalledConnected) { + return; + } + + mCalledConnected = true; + + onBinderConnected(service); + } + + private void onBinderConnectionLostInternal() { + XPCOMEventTarget.assertOnLauncherThread(); + // We only care about the first time this is called; subsequent connection errors can be + // ignored. + if (mCalledConnectionLost) { + return; + } + + mCalledConnectionLost = true; + + onBinderConnectionLost(); + } + + protected abstract void onBinderConnected(@NonNull final IBinder service); + + protected abstract void onReleaseResources(); + + // Optionally overridable by subclasses, but this is a sane default + protected void onBinderConnectionLost() { + // The binding has lost its connection, but the binding itself might still be active. + // Gecko itself will request a process restart, so here we attempt to unbind so that + // Android does not try to automatically restart and reconnect the service. + unbindService(); + } + + /** + * This function relies on the fact that the PriorityLevel enum is ordered from highest priority + * to lowest priority. We examine the ordinal of the current priority setting, and then iterate + * across all possible priority levels, adjusting as necessary. Any priority levels whose + * ordinals are less than then current priority level ordinal must be unbound, while all + * priority levels whose ordinals are greater than or equal to the current priority level + * ordinal must be bound. + */ + @TargetApi(29) + private boolean updateBindings() { + XPCOMEventTarget.assertOnLauncherThread(); + int numBindSuccesses = 0; + int numBindFailures = 0; + int numUnbindSuccesses = 0; + + final Context context = GeckoAppShell.getApplicationContext(); + + // This code assumes that the order of the PriorityLevel enum is highest to lowest + final int curPriorityOrdinal = mCurrentPriority.ordinal(); + final PriorityLevel[] levels = PriorityLevel.values(); + + for (int curLevelIdx = 0; curLevelIdx < levels.length; ++curLevelIdx) { + final PriorityLevel curLevel = levels[curLevelIdx]; + final Binding existingBinding = mBindings.get(curLevel); + final boolean hasExistingBinding = existingBinding != null; + + if (curLevelIdx < curPriorityOrdinal) { + // Remove if present + if (hasExistingBinding) { + try { + context.unbindService(existingBinding); + ++numUnbindSuccesses; + mBindings.remove(curLevel); + } catch (final IllegalArgumentException e) { + // The binding was already dead. That's okay. + ++numUnbindSuccesses; + mBindings.remove(curLevel); + } + } + } else { + // Normally we only need to do a bind if we do not yet have an existing binding + // for this priority level. + boolean bindNeeded = !hasExistingBinding; + + // We only update the service group when the binding for this level already + // exists and no binds have occurred yet during the current updateBindings call. + if (hasExistingBinding && hasQApis() && (numBindSuccesses + numBindFailures) == 0) { + // NB: Right now we're passing 0 as the |group| argument, indicating that + // the process is not grouped with any other processes. Once we support + // Fission we should re-evaluate this. + context.updateServiceGroup(existingBinding, 0, mRelativeImportance); + // Now we need to call bindService with the existing binding to make this + // change take effect. + bindNeeded = true; + } + + if (bindNeeded) { + final Binding useBinding = hasExistingBinding ? existingBinding : this.new Binding(); + if (mBindDelegate.bindService(useBinding, curLevel)) { + ++numBindSuccesses; + if (!hasExistingBinding) { + mBindings.put(curLevel, useBinding); + } + } else { + ++numBindFailures; + } + } + } + } + + final String svcName = mBindDelegate.getServiceName(); + final StringBuilder builder = new StringBuilder(svcName); + builder + .append(" updateBindings: ") + .append(mCurrentPriority) + .append(" priority, ") + .append(mRelativeImportance) + .append(" importance, ") + .append(numBindSuccesses) + .append(" successful binds, ") + .append(numBindFailures) + .append(" failed binds, ") + .append(numUnbindSuccesses) + .append(" successful unbinds"); + Log.d(LOGTAG, builder.toString()); + + return numBindFailures == 0; + } + } + + private interface ContentAllocationPolicy { + /** + * @return BindServiceDelegate that will be used for binding a new content service. + */ + BindServiceDelegate getBindServiceDelegate(InstanceInfo info); + + /** + * Allocate an unused service ID for use by the caller. + * + * @return The new service id. + */ + String allocate(); + + /** + * Release a previously used service ID. + * + * @param id The service id being released. + */ + void release(final String id); + } + + /** + * This policy is intended for Android versions < 10, as well as for content process services + * that are not defined as isolated processes. In this case, the number of possible content + * service IDs has a fixed upper bound, so we use a BitSet to manage their allocation. + */ + private static final class DefaultContentPolicy implements ContentAllocationPolicy { + private final int mMaxNumSvcs; + private final BitSet mAllocator; + private final SecureRandom mRandom; + + public DefaultContentPolicy() { + mMaxNumSvcs = getContentServiceCount(); + mAllocator = new BitSet(mMaxNumSvcs); + mRandom = new SecureRandom(); + } + + @Override + public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) { + return info.new DefaultBindDelegate(); + } + + @Override + public String allocate() { + final int[] available = new int[mMaxNumSvcs]; + int size = 0; + for (int i = 0; i < mMaxNumSvcs; i++) { + if (!mAllocator.get(i)) { + available[size] = i; + size++; + } + } + + if (size == 0) { + throw new RuntimeException("No more content services available"); + } + + final int next = available[mRandom.nextInt(size)]; + mAllocator.set(next); + return Integer.toString(next); + } + + @Override + public void release(final String stringId) { + final int id = Integer.valueOf(stringId); + if (!mAllocator.get(id)) { + throw new IllegalStateException("Releasing an unallocated id=" + id); + } + + mAllocator.clear(id); + } + + /** + * @return The number of content services defined in our manifest. + */ + private static int getContentServiceCount() { + return ServiceUtils.getServiceCount( + GeckoAppShell.getApplicationContext(), GeckoProcessType.CONTENT); + } + } + + /** + * This policy is intended for Android versions >= 10 when our content process services are + * defined in our manifest as having isolated processes. Since isolated services share a single + * service definition, there is no longer an Android-induced hard limit on the number of content + * processes that may be started. We simply use a monotonically-increasing counter to generate + * unique instance IDs in this case. + */ + private static final class IsolatedContentPolicy implements ContentAllocationPolicy { + private final Set<String> mRunningServiceIds = new HashSet<>(); + + @Override + public BindServiceDelegate getBindServiceDelegate(@NonNull final InstanceInfo info) { + return info.new IsolatedBindDelegate(); + } + + /** + * We generate a new instance ID simply by incrementing a counter. We do track how many content + * services are currently active for the purposes of maintaining the configured limit on number + * of simultaneous content processes. + */ + @Override + public String allocate() { + if (mRunningServiceIds.size() >= MAX_NUM_ISOLATED_CONTENT_SERVICES) { + throw new RuntimeException("No more content services available"); + } + + final String newId = UUID.randomUUID().toString(); + mRunningServiceIds.add(newId); + return newId; + } + + /** Just drop the count of active services. */ + @Override + public void release(final String id) { + if (!mRunningServiceIds.remove(id)) { + throw new IllegalStateException("Releasing an unallocated id"); + } + } + } + + /** The policy used for allocating content processes. */ + private ContentAllocationPolicy mContentAllocPolicy = null; + + /** + * Allocate a service ID. + * + * @param type The type of service. + * @return Integer encapsulating the service ID, or null if no ID is necessary. + */ + private String allocate(@NonNull final GeckoProcessType type) { + XPCOMEventTarget.assertOnLauncherThread(); + if (type != GeckoProcessType.CONTENT) { + // No unique id necessary + return null; + } + + // Lazy initialization of mContentAllocPolicy to ensure that it is constructed on the + // launcher thread. + if (mContentAllocPolicy == null) { + if (canBindIsolated(GeckoProcessType.CONTENT)) { + mContentAllocPolicy = new IsolatedContentPolicy(); + } else { + mContentAllocPolicy = new DefaultContentPolicy(); + } + } + + return mContentAllocPolicy.allocate(); + } + + /** + * Free a defunct service's ID if necessary. + * + * @param info The InstanceInfo-derived object that contains essential information for tearing + * down the child service. + */ + private void release(@NonNull final InstanceInfo info) { + XPCOMEventTarget.assertOnLauncherThread(); + if (!info.isContent()) { + return; + } + + mContentAllocPolicy.release(info.getId()); + } + + /** + * Find out whether the desired service type is defined in our manifest as having an isolated + * process. + * + * @param type Service type to query + * @return true if this service type may use isolated binding, otherwise false. + */ + private static boolean canBindIsolated(@NonNull final GeckoProcessType type) { + if (!hasQApis()) { + return false; + } + + final Context context = GeckoAppShell.getApplicationContext(); + final int svcFlags = ServiceUtils.getServiceFlags(context, type); + return (svcFlags & ServiceInfo.FLAG_ISOLATED_PROCESS) != 0; + } + + /** Convert PriorityLevel into the flags argument to Context.bindService() et al */ + private static int getAndroidFlags(@NonNull final PriorityLevel priority) { + return Context.BIND_AUTO_CREATE | priority.getAndroidFlag(); + } + + /** Obtain the class name to use for service binding in the default (ie, non-isolated) case. */ + private static String getSvcClassNameDefault(@NonNull final InstanceInfo info) { + return ServiceUtils.buildSvcName(info.getType(), info.getIdInternal()); + } + + /** + * Wrapper for bindService() that utilizes the Context.bindService() overload that accepts an + * Executor argument, when available. Otherwise it falls back to the legacy overload. + */ + @TargetApi(29) + private static boolean bindServiceDefault( + @NonNull final Context context, + @NonNull final Intent intent, + @NonNull final ServiceConnection conn, + final int flags) { + if (hasQApis()) { + // We always specify the launcher thread as our Executor. + return context.bindService(intent, flags, XPCOMEventTarget.launcherThread(), conn); + } + + return context.bindService(intent, conn, flags); + } + + @TargetApi(29) + private static boolean bindServiceIsolated( + @NonNull final Context context, + @NonNull final Intent intent, + final int flags, + @NonNull final String instanceId, + @NonNull final ServiceConnection conn) { + // We always specify the launcher thread as our Executor. + return context.bindIsolatedService( + intent, flags, instanceId, XPCOMEventTarget.launcherThread(), conn); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java new file mode 100644 index 0000000000..695c69666b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/process/ServiceUtils.java @@ -0,0 +1,141 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.gecko.process; + +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import androidx.annotation.NonNull; + +/* package */ final class ServiceUtils { + private static final String DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX = "0"; + + private ServiceUtils() {} + + /** + * @return StringBuilder containing the name of a service class but not qualifed with any unique + * identifiers. + */ + private static StringBuilder startSvcName(@NonNull final GeckoProcessType type) { + final StringBuilder builder = new StringBuilder(GeckoChildProcessServices.class.getName()); + builder.append("$").append(type); + return builder; + } + + /** + * Given a service's GeckoProcessType, obtain the name of its class, including any qualifiers that + * are needed to uniquely identify its manifest definition. + */ + public static String buildSvcName( + @NonNull final GeckoProcessType type, final String... suffixes) { + final StringBuilder builder = startSvcName(type); + + for (final String suffix : suffixes) { + builder.append(suffix); + } + + return builder.toString(); + } + + /** + * Given a service's GeckoProcessType, obtain the name of its class to be used for the purpose of + * binding as an isolated service. + * + * <p>Content services are defined in the manifest as "tab0" through "tabN" for some value of N. + * For the purposes of binding to an isolated content service, we simply need to repeatedly re-use + * the definition of "tab0", the "0" being stored as the + * DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX constant. + */ + public static String buildIsolatedSvcName(@NonNull final GeckoProcessType type) { + if (type == GeckoProcessType.CONTENT) { + return buildSvcName(type, DEFAULT_ISOLATED_CONTENT_SERVICE_NAME_SUFFIX); + } + + // Non-content services do not require any unique IDs + return buildSvcName(type); + } + + /** + * Given a service's GeckoProcessType, obtain the unqualified name of its class. + * + * @return The name of the class that hosts the implementation of the service corresponding to + * type, but without any unique identifiers that may be required to actually instantiate it. + */ + private static String buildSvcNamePrefix(@NonNull final GeckoProcessType type) { + return startSvcName(type).toString(); + } + + /** + * Extracts flags from the manifest definition of a service. + * + * @param context Context to use for extraction + * @param type Service type + * @return flags that are specified in the service's definition in our manifest. + * @see android.content.pm.ServiceInfo for explanation of the various flags. + */ + public static int getServiceFlags( + @NonNull final Context context, @NonNull final GeckoProcessType type) { + final ComponentName component = new ComponentName(context, buildIsolatedSvcName(type)); + final PackageManager pkgMgr = context.getPackageManager(); + + try { + final ServiceInfo svcInfo = pkgMgr.getServiceInfo(component, 0); + // svcInfo is never null + return svcInfo.flags; + } catch (final PackageManager.NameNotFoundException e) { + throw new RuntimeException(e); + } + } + + /** Obtain the list of all services defined for |context|. */ + private static ServiceInfo[] getServiceList(@NonNull final Context context) { + final PackageInfo packageInfo; + try { + packageInfo = + context + .getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.GET_SERVICES); + } catch (final PackageManager.NameNotFoundException e) { + throw new AssertionError("Should not happen: Can't get package info of own package"); + } + return packageInfo.services; + } + + /** + * Count the number of service definitions in our manifest that satisfy bindings for a particular + * service type. + * + * @param context Context object to use for extracting the service definitions + * @param type The type of service to count + * @return The number of available service definitions. + */ + public static int getServiceCount( + @NonNull final Context context, @NonNull final GeckoProcessType type) { + final ServiceInfo[] svcList = getServiceList(context); + final String serviceNamePrefix = buildSvcNamePrefix(type); + + int result = 0; + for (final ServiceInfo svc : svcList) { + final String svcName = svc.name; + // If svcName starts with serviceNamePrefix, then both strings must either be equal + // or else the first subsequent character in svcName must be a digit. + // This guards against any future GeckoProcessType whose string representation shares + // a common prefix with another GeckoProcessType value. + if (svcName.startsWith(serviceNamePrefix) + && (svcName.length() == serviceNamePrefix.length() + || Character.isDigit(svcName.codePointAt(serviceNamePrefix.length())))) { + ++result; + } + } + + if (result <= 0) { + throw new RuntimeException("Could not count " + serviceNamePrefix + " services in manifest"); + } + + return result; + } +} 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 0000000000..b8d7ea3107 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/BundleEventListener.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.util; + +import org.mozilla.gecko.annotation.RobocopTarget; + +@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 call included a callback argument; otherwise, callback will be null. + */ + void handleMessage(String event, GeckoBundle message, EventCallback callback); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java new file mode 100644 index 0000000000..c1be67e356 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/DebugConfig.java @@ -0,0 +1,134 @@ +/* -*- 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.Build; +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.mozilla.gecko.annotation.ReflectionTarget; +import org.yaml.snakeyaml.TypeDescription; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.error.YAMLException; + +// Raptor writes a *-config.yaml file to specify Gecko runtime settings (e.g. +// the profile dir). This file gets deserialized into a DebugConfig object. +// Yaml uses reflection to create this class so we have to tell PG to keep it. +@ReflectionTarget +public class DebugConfig { + private static final String LOGTAG = "GeckoDebugConfig"; + + protected Map<String, Object> prefs; + protected Map<String, String> env; + protected List<String> args; + + public static class ConfigException extends RuntimeException { + public ConfigException(final String message) { + super(message); + } + } + + public static @NonNull DebugConfig fromFile(final @NonNull File configFile) + throws FileNotFoundException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + // There are a lot of problems with SnakeYaml on older version let's just bail. + throw new ConfigException("Config version is only supported for SDK_INT >= 21."); + } + + final Constructor constructor = new Constructor(DebugConfig.class); + final TypeDescription description = new TypeDescription(DebugConfig.class); + description.putMapPropertyType("prefs", String.class, Object.class); + description.putMapPropertyType("env", String.class, String.class); + description.putListPropertyType("args", String.class); + + final Yaml yaml = new Yaml(constructor); + yaml.addTypeDescription(description); + + final FileInputStream fileInputStream = new FileInputStream(configFile); + try { + return yaml.load(fileInputStream); + } catch (final YAMLException e) { + throw new ConfigException(e.getMessage()); + } finally { + try { + if (fileInputStream != null) { + ((Closeable) fileInputStream).close(); + } + } catch (final IOException e) { + } + } + } + + @Nullable + public Bundle mergeIntoExtras(final @Nullable Bundle extras) { + if (env == null) { + return extras; + } + + Log.d(LOGTAG, "Adding environment variables from debug config: " + env); + + final Bundle result = extras != null ? extras : new Bundle(); + + int c = 0; + while (result.getString("env" + c) != null) { + c += 1; + } + + for (final Map.Entry<String, String> entry : env.entrySet()) { + result.putString("env" + c, entry.getKey() + "=" + entry.getValue()); + c += 1; + } + + return result; + } + + @Nullable + public String[] mergeIntoArgs(final @Nullable String[] initArgs) { + if (args == null) { + return initArgs; + } + + Log.d(LOGTAG, "Adding arguments from debug config: " + args); + + final ArrayList<String> combinedArgs = new ArrayList<>(); + if (initArgs != null) { + combinedArgs.addAll(Arrays.asList(initArgs)); + } + combinedArgs.addAll(args); + + return combinedArgs.toArray(new String[combinedArgs.size()]); + } + + @Nullable + public Map<String, Object> mergeIntoPrefs(final @Nullable Map<String, Object> initPrefs) { + if (prefs == null) { + return initPrefs; + } + + Log.d(LOGTAG, "Adding prefs from debug config: " + prefs); + + final Map<String, Object> combinedPrefs = new HashMap<>(); + if (initPrefs != null) { + combinedPrefs.putAll(initPrefs); + } + combinedPrefs.putAll(prefs); + + return Collections.unmodifiableMap(combinedPrefs); + } +} 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 0000000000..3ef469ac1b --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/EventCallback.java @@ -0,0 +1,58 @@ +/* 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 androidx.annotation.Nullable; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.geckoview.GeckoResult; + +/** + * Callback interface for Gecko requests. + * + * <p>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 +@WrapForJNI(calledFrom = "gecko") +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). + */ + 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). + */ + void sendError(Object response); + + /** + * Resolve this Event callback with the result from the {@link GeckoResult}. + * + * @param response the result that will be used for this callback. + */ + default <T> void resolveTo(final @Nullable GeckoResult<T> response) { + if (response == null) { + sendSuccess(null); + return; + } + response.accept( + this::sendSuccess, + throwable -> { + // Don't propagate Errors, just crash + if (!(throwable instanceof Exception)) { + throw new GeckoResult.UncaughtException(throwable); + } + sendError(throwable.getMessage()); + }); + } +} 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 0000000000..01b177fe21 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBackgroundThread.java @@ -0,0 +1,72 @@ +/* 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; + +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 mInitialRunnable; + + // Singleton, so private constructor. + private GeckoBackgroundThread(final Runnable initialRunnable) { + mInitialRunnable = initialRunnable; + } + + @Override + public void run() { + setName(LOOPER_NAME); + Looper.prepare(); + + synchronized (GeckoBackgroundThread.class) { + handler = new Handler(); + GeckoBackgroundThread.class.notifyAll(); + } + + if (mInitialRunnable != null) { + mInitialRunnable.run(); + mInitialRunnable = null; + } + + Looper.loop(); + } + + private static void startThread(final Runnable initialRunnable) { + thread = new GeckoBackgroundThread(initialRunnable); + 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/GeckoBundle.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java new file mode 100644 index 0000000000..de7de66597 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/GeckoBundle.java @@ -0,0 +1,1152 @@ +/* -*- 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.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.collection.SimpleArrayMap; +import java.lang.reflect.Array; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * A lighter-weight version of Bundle that adds support for type coercion (e.g. int to double) in + * order to better cooperate with JS objects. + */ +@RobocopTarget +public final class GeckoBundle implements Parcelable { + private static final String LOGTAG = "GeckoBundle"; + private static final boolean DEBUG = false; + + @WrapForJNI(calledFrom = "gecko") + private static final boolean[] EMPTY_BOOLEAN_ARRAY = new boolean[0]; + + private static final int[] EMPTY_INT_ARRAY = new int[0]; + private static final long[] EMPTY_LONG_ARRAY = new long[0]; + private static final double[] EMPTY_DOUBLE_ARRAY = new double[0]; + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + private static final GeckoBundle[] EMPTY_BUNDLE_ARRAY = new GeckoBundle[0]; + + private SimpleArrayMap<String, Object> mMap; + + /** Construct an empty GeckoBundle. */ + public GeckoBundle() { + mMap = new SimpleArrayMap<>(); + } + + /** + * Construct an empty GeckoBundle with specific capacity. + * + * @param capacity Initial capacity. + */ + public GeckoBundle(final int capacity) { + mMap = new SimpleArrayMap<>(capacity); + } + + /** + * Construct a copy of another GeckoBundle. + * + * @param bundle GeckoBundle to copy from. + */ + public GeckoBundle(final GeckoBundle bundle) { + mMap = new SimpleArrayMap<>(bundle.mMap); + } + + @WrapForJNI(calledFrom = "gecko") + private GeckoBundle(final String[] keys, final Object[] values) { + final int len = keys.length; + mMap = new SimpleArrayMap<>(len); + for (int i = 0; i < len; i++) { + mMap.put(keys[i], values[i]); + } + } + + /** Clear all mappings. */ + public void clear() { + mMap.clear(); + } + + /** + * Returns whether a mapping exists. Null String, Bundle, or arrays are treated as nonexistent. + * + * @param key Key to look for. + * @return True if the specified key exists and the value is not null. + */ + public boolean containsKey(final String key) { + return mMap.get(key) != null; + } + + /** + * Returns the value associated with a mapping as an Object. + * + * @param key Key to look for. + * @return Mapping value or null if the mapping does not exist. + */ + public Object get(final String key) { + return mMap.get(key); + } + + /** + * Returns the value associated with a boolean mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Boolean value + */ + public boolean getBoolean(final String key, final boolean defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : (Boolean) value; + } + + /** + * Returns the value associated with a boolean mapping, or false if the mapping does not exist. + * + * @param key Key to look for. + * @return Boolean value + */ + public boolean getBoolean(final String key) { + return getBoolean(key, false); + } + + /** + * Returns the value associated with a Boolean mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Boolean value + */ + public Boolean getBooleanObject(final String key, final Boolean defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : (Boolean) value; + } + + /** + * Returns the value associated with a Boolean mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Boolean value + */ + public Boolean getBooleanObject(final String key) { + return getBooleanObject(key, null); + } + + /** + * Returns the value associated with a boolean array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return Boolean array value + */ + public boolean[] getBooleanArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 ? EMPTY_BOOLEAN_ARRAY : (boolean[]) value; + } + + /** + * Returns the value associated with a double mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Double value + */ + public double getDouble(final String key, final double defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : ((Number) value).doubleValue(); + } + + /** + * Returns the value associated with a double mapping, or 0.0 if the mapping does not exist. + * + * @param key Key to look for. + * @return Double value + */ + public double getDouble(final String key) { + return getDouble(key, 0.0); + } + + private static double[] getDoubleArray(final int[] array) { + final int len = array.length; + final double[] ret = new double[len]; + for (int i = 0; i < len; i++) { + ret[i] = (double) array[i]; + } + return ret; + } + + /** + * Returns the value associated with a double array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return Double array value + */ + public double[] getDoubleArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_DOUBLE_ARRAY + : value instanceof int[] ? getDoubleArray((int[]) value) : (double[]) value; + } + + /** + * Returns the value associated with an int mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Int value + */ + public int getInt(final String key, final int defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : ((Number) value).intValue(); + } + + /** + * Returns the value associated with an int mapping, or 0 if the mapping does not exist. + * + * @param key Key to look for. + * @return Int value + */ + public int getInt(final String key) { + return getInt(key, 0); + } + + /** + * Returns the value associated with an Integer mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Int value + */ + public Integer getInteger(final String key, final Integer defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : ((Integer) value); + } + + /** + * Returns the value associated with an Integer mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Int value + */ + public Integer getInteger(final String key) { + return getInteger(key, null); + } + + private static int[] getIntArray(final double[] array) { + final int len = array.length; + final int[] ret = new int[len]; + for (int i = 0; i < len; i++) { + ret[i] = (int) array[i]; + } + return ret; + } + + /** + * Returns the value associated with an int array mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Int array value + */ + public int[] getIntArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_INT_ARRAY + : value instanceof double[] ? getIntArray((double[]) value) : (int[]) value; + } + + /** + * Returns the value associated with an int/double mapping as a long value, or defaultValue if the + * mapping does not exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping does not exist. + * @return Long value + */ + public long getLong(final String key, final long defaultValue) { + final Object value = mMap.get(key); + return value == null ? defaultValue : ((Number) value).longValue(); + } + + /** + * Returns the value associated with an int/double mapping as a long value, or 0 if the mapping + * does not exist. + * + * @param key Key to look for. + * @return Long value + */ + public long getLong(final String key) { + return getLong(key, 0L); + } + + private static long[] getLongArray(final Object array) { + final int len = Array.getLength(array); + final long[] ret = new long[len]; + for (int i = 0; i < len; i++) { + ret[i] = ((Number) Array.get(array, i)).longValue(); + } + return ret; + } + + /** + * Returns the value associated with an int/double array mapping as a long array, or null if the + * mapping does not exist. + * + * @param key Key to look for. + * @return Long array value + */ + public long[] getLongArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 ? EMPTY_LONG_ARRAY : getLongArray(value); + } + + /** + * Returns the value associated with a String mapping, or defaultValue if the mapping does not + * exist. + * + * @param key Key to look for. + * @param defaultValue Value to return if mapping value is null or mapping does not exist. + * @return String value + */ + public String getString(final String key, final String defaultValue) { + // If the key maps to null, technically we should return null because the mapping + // exists and null is a valid string value. However, people expect the default + // value to be returned instead, so we make an exception to return the default value. + final Object value = mMap.get(key); + return value == null ? defaultValue : (String) value; + } + + /** + * Returns the value associated with a String mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return String value + */ + public String getString(final String key) { + return getString(key, null); + } + + // The only case where we convert String[] to/from GeckoBundle[] is if every element + // is null. + private static int getNullArrayLength(final Object array) { + final int len = Array.getLength(array); + for (int i = 0; i < len; i++) { + if (Array.get(array, i) != null) { + throw new ClassCastException("Cannot cast array type"); + } + } + return len; + } + + /** + * Returns the value associated with a String array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return String array value + */ + public String[] getStringArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_STRING_ARRAY + : !(value instanceof String[]) + ? new String[getNullArrayLength(value)] + : (String[]) value; + } + + /* + * Returns the value associated with a RectF mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return RectF value + */ + public RectF getRectF(final String key) { + final GeckoBundle rectBundle = getBundle(key); + if (rectBundle == null) { + return null; + } + + return new RectF( + (float) rectBundle.getDouble("left"), + (float) rectBundle.getDouble("top"), + (float) rectBundle.getDouble("right"), + (float) rectBundle.getDouble("bottom")); + } + + /** + * Returns the value associated with a Point mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Point value + */ + public Point getPoint(final String key) { + final GeckoBundle ptBundle = getBundle(key); + if (ptBundle == null) { + return null; + } + + return new Point(ptBundle.getInt("x"), ptBundle.getInt("y")); + } + + /** + * Returns the value associated with a PointF mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return Point value + */ + public PointF getPointF(final String key) { + final GeckoBundle ptBundle = getBundle(key); + if (ptBundle == null) { + return null; + } + + return new PointF((float) ptBundle.getDouble("x"), (float) ptBundle.getDouble("y")); + } + + /** + * Returns the value associated with a GeckoBundle mapping, or null if the mapping does not exist. + * + * @param key Key to look for. + * @return GeckoBundle value + */ + public GeckoBundle getBundle(final String key) { + return (GeckoBundle) mMap.get(key); + } + + /** + * Returns the value associated with a GeckoBundle array mapping, or null if the mapping does not + * exist. + * + * @param key Key to look for. + * @return GeckoBundle array value + */ + public GeckoBundle[] getBundleArray(final String key) { + final Object value = mMap.get(key); + return value == null + ? null + : Array.getLength(value) == 0 + ? EMPTY_BUNDLE_ARRAY + : !(value instanceof GeckoBundle[]) + ? new GeckoBundle[getNullArrayLength(value)] + : (GeckoBundle[]) value; + } + + /** + * Returns whether this GeckoBundle has no mappings. + * + * @return True if no mapping exists. + */ + public boolean isEmpty() { + return mMap.isEmpty(); + } + + /** + * Returns an array of all mapped keys. + * + * @return String array containing all mapped keys. + */ + @WrapForJNI(calledFrom = "gecko") + public String[] keys() { + final int len = mMap.size(); + final String[] ret = new String[len]; + for (int i = 0; i < len; i++) { + ret[i] = mMap.keyAt(i); + } + return ret; + } + + @WrapForJNI(calledFrom = "gecko") + private Object[] values() { + final int len = mMap.size(); + final Object[] ret = new Object[len]; + for (int i = 0; i < len; i++) { + ret[i] = mMap.valueAt(i); + } + return ret; + } + + private void put(final String key, final Object value) { + // We intentionally disallow a generic put() method for type safety and sanity. For + // example, we assume elsewhere in the code that a value belongs to a small list of + // predefined types, and cannot be any arbitrary object. If you want to put an + // Object in the bundle, check the type of the Object first and call the + // corresponding put methods. For example, + // + // if (obj instanceof Integer) { + // bundle.putInt(key, (Integer) key); + // } else if (obj instanceof String) { + // bundle.putString(key, (String) obj); + // } else { + // throw new IllegalArgumentException("unexpected type"); + // } + throw new UnsupportedOperationException(); + } + + /** + * Map a key to a boolean value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBoolean(final String key, final boolean value) { + mMap.put(key, value); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final boolean[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final Boolean[] value) { + if (value == null) { + mMap.put(key, null); + return; + } + final boolean[] array = new boolean[value.length]; + for (int i = 0; i < value.length; i++) { + array[i] = value[i]; + } + mMap.put(key, array); + } + + /** + * Map a key to a boolean array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBooleanArray(final String key, final Collection<Boolean> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final boolean[] array = new boolean[value.size()]; + int i = 0; + for (final Boolean element : value) { + array[i++] = element; + } + mMap.put(key, array); + } + + /** + * Map a key to a double value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putDouble(final String key, final double value) { + mMap.put(key, value); + } + + /** + * Map a key to a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putDoubleArray(final String key, final double[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putDoubleArray(final String key, final Double[] value) { + putDoubleArray(key, Arrays.asList(value)); + } + + /** + * Map a key to a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putDoubleArray(final String key, final Collection<Double> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final double[] array = new double[value.size()]; + int i = 0; + for (final Double element : value) { + array[i++] = element; + } + mMap.put(key, array); + } + + /** + * Map a key to an int value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putInt(final String key, final int value) { + mMap.put(key, value); + } + + /** + * Map a key to an int array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putIntArray(final String key, final int[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a int array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putIntArray(final String key, final Integer[] value) { + putIntArray(key, Arrays.asList(value)); + } + + /** + * Map a key to a int array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putIntArray(final String key, final Collection<Integer> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final int[] array = new int[value.size()]; + int i = 0; + for (final Integer element : value) { + array[i++] = element; + } + mMap.put(key, array); + } + + /** + * Map a key to a long value stored as a double value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putLong(final String key, final long value) { + mMap.put(key, (double) value); + } + + /** + * Map a key to a long array value stored as a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putLongArray(final String key, final long[] value) { + if (value == null) { + mMap.put(key, null); + return; + } + final double[] array = new double[value.length]; + for (int i = 0; i < value.length; i++) { + array[i] = (double) value[i]; + } + mMap.put(key, array); + } + + /** + * Map a key to a long array value stored as a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putLongArray(final String key, final Long[] value) { + putLongArray(key, Arrays.asList(value)); + } + + /** + * Map a key to a long array value stored as a double array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putLongArray(final String key, final Collection<Long> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final double[] array = new double[value.size()]; + int i = 0; + for (final Long element : value) { + array[i++] = (double) element; + } + mMap.put(key, array); + } + + /** + * Map a key to a String value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putString(final String key, final String value) { + mMap.put(key, value); + } + + /** + * Map a key to a String array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putStringArray(final String key, final String[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a String array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putStringArray(final String key, final Collection<String> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final String[] array = new String[value.size()]; + int i = 0; + for (final String element : value) { + array[i++] = element; + } + mMap.put(key, array); + } + + /** + * Map a key to a GeckoBundle value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBundle(final String key, final GeckoBundle value) { + mMap.put(key, value); + } + + /** + * Map a key to a GeckoBundle array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBundleArray(final String key, final GeckoBundle[] value) { + mMap.put(key, value); + } + + /** + * Map a key to a GeckoBundle array value. + * + * @param key Key to map. + * @param value Value to map to. + */ + public void putBundleArray(final String key, final Collection<GeckoBundle> value) { + if (value == null) { + mMap.put(key, null); + return; + } + final GeckoBundle[] array = new GeckoBundle[value.size()]; + int i = 0; + for (final GeckoBundle element : value) { + array[i++] = element; + } + mMap.put(key, array); + } + + /** + * Remove a mapping. + * + * @param key Key to remove. + */ + public void remove(final String key) { + mMap.remove(key); + } + + /** + * Returns number of mappings in this GeckoBundle. + * + * @return Number of mappings. + */ + public int size() { + return mMap.size(); + } + + private static Object normalizeValue(final Object value) { + if (value instanceof Integer) { + // We treat int and double as the same type. + return ((Integer) value).doubleValue(); + + } else if (value instanceof int[]) { + // We treat int[] and double[] as the same type. + final int[] array = (int[]) value; + return array.length == 0 ? EMPTY_STRING_ARRAY : getDoubleArray(array); + + } else if (value != null && value.getClass().isArray()) { + // We treat arrays of all nulls as the same type, including empty arrays. + final int len = Array.getLength(value); + for (int i = 0; i < len; i++) { + if (Array.get(value, i) != null) { + return value; + } + } + return len == 0 ? EMPTY_STRING_ARRAY : new String[len]; + } + return value; + } + + @Override // Object + public boolean equals(final Object other) { + if (!(other instanceof GeckoBundle)) { + return false; + } + + // Support library's SimpleArrayMap.equals is buggy, so roll our own version. + final SimpleArrayMap<String, Object> otherMap = ((GeckoBundle) other).mMap; + if (mMap == otherMap) { + return true; + } + if (mMap.size() != otherMap.size()) { + return false; + } + + for (int i = 0; i < mMap.size(); i++) { + final String thisKey = mMap.keyAt(i); + final int otherKey = otherMap.indexOfKey(thisKey); + if (otherKey < 0) { + return false; + } + final Object thisValue = normalizeValue(mMap.valueAt(i)); + final Object otherValue = normalizeValue(otherMap.valueAt(otherKey)); + if (thisValue == otherValue) { + continue; + } else if (thisValue == null || otherValue == null) { + return false; + } + + final Class<?> thisClass = thisValue.getClass(); + final Class<?> otherClass = otherValue.getClass(); + if (thisClass != otherClass && !thisClass.equals(otherClass)) { + return false; + } else if (!thisClass.isArray()) { + if (!thisValue.equals(otherValue)) { + return false; + } + continue; + } + + // Work with both primitive arrays and Object arrays, unlike Arrays.equals(). + final int thisLen = Array.getLength(thisValue); + final int otherLen = Array.getLength(otherValue); + if (thisLen != otherLen) { + return false; + } + for (int j = 0; j < thisLen; j++) { + final Object thisElem = Array.get(thisValue, j); + final Object otherElem = Array.get(otherValue, j); + if (thisElem != otherElem + && (thisElem == null || otherElem == null || !thisElem.equals(otherElem))) { + return false; + } + } + } + return true; + } + + @Override // Object + public int hashCode() { + return mMap.hashCode(); + } + + @Override // Object + public String toString() { + return mMap.toString(); + } + + public JSONObject toJSONObject() throws JSONException { + final JSONObject out = new JSONObject(); + for (int i = 0; i < mMap.size(); i++) { + final Object value = mMap.valueAt(i); + final Object jsonValue; + + if (value instanceof GeckoBundle) { + jsonValue = ((GeckoBundle) value).toJSONObject(); + } else if (value instanceof GeckoBundle[]) { + final GeckoBundle[] array = (GeckoBundle[]) value; + final JSONArray jsonArray = new JSONArray(); + for (final GeckoBundle element : array) { + jsonArray.put(element == null ? JSONObject.NULL : element.toJSONObject()); + } + jsonValue = jsonArray; + } else if (Build.VERSION.SDK_INT >= 19) { + final Object wrapped = JSONObject.wrap(value); + jsonValue = wrapped != null ? wrapped : value.toString(); + } else if (value == null) { + jsonValue = JSONObject.NULL; + } else if (value.getClass().isArray()) { + final JSONArray jsonArray = new JSONArray(); + for (int j = 0; j < Array.getLength(value); j++) { + jsonArray.put(Array.get(value, j)); + } + jsonValue = jsonArray; + } else { + jsonValue = value; + } + out.put(mMap.keyAt(i), jsonValue); + } + return out; + } + + public Bundle toBundle() { + final Bundle out = new Bundle(mMap.size()); + for (int i = 0; i < mMap.size(); i++) { + final String key = mMap.keyAt(i); + final Object val = mMap.valueAt(i); + + if (val == null) { + out.putString(key, null); + } else if (val instanceof GeckoBundle) { + out.putBundle(key, ((GeckoBundle) val).toBundle()); + } else if (val instanceof GeckoBundle[]) { + final GeckoBundle[] array = (GeckoBundle[]) val; + final Parcelable[] parcelables = new Parcelable[array.length]; + for (int j = 0; j < array.length; j++) { + if (array[j] != null) { + parcelables[j] = array[j].toBundle(); + } + } + out.putParcelableArray(key, parcelables); + } else if (val instanceof Boolean) { + out.putBoolean(key, (Boolean) val); + } else if (val instanceof boolean[]) { + out.putBooleanArray(key, (boolean[]) val); + } else if (val instanceof Byte || val instanceof Short || val instanceof Integer) { + out.putInt(key, ((Number) val).intValue()); + } else if (val instanceof int[]) { + out.putIntArray(key, (int[]) val); + } else if (val instanceof Float || val instanceof Double || val instanceof Long) { + out.putDouble(key, ((Number) val).doubleValue()); + } else if (val instanceof double[]) { + out.putDoubleArray(key, (double[]) val); + } else if (val instanceof CharSequence || val instanceof Character) { + out.putString(key, val.toString()); + } else if (val instanceof String[]) { + out.putStringArray(key, (String[]) val); + } else { + throw new UnsupportedOperationException(); + } + } + return out; + } + + public static GeckoBundle fromBundle(final Bundle bundle) { + if (bundle == null) { + return null; + } + + final String[] keys = new String[bundle.size()]; + final Object[] values = new Object[bundle.size()]; + int i = 0; + + for (final String key : bundle.keySet()) { + final Object value = bundle.get(key); + keys[i] = key; + + if (value instanceof Bundle || value == null) { + values[i] = fromBundle((Bundle) value); + } else if (value instanceof Parcelable[]) { + final Parcelable[] array = (Parcelable[]) value; + final GeckoBundle[] out = new GeckoBundle[array.length]; + for (int j = 0; j < array.length; j++) { + out[j] = fromBundle((Bundle) array[j]); + } + values[i] = out; + } else if (value instanceof Boolean + || value instanceof Integer + || value instanceof Double + || value instanceof String + || value instanceof boolean[] + || value instanceof int[] + || value instanceof double[] + || value instanceof String[]) { + values[i] = value; + } else if (value instanceof Byte || value instanceof Short) { + values[i] = ((Number) value).intValue(); + } else if (value instanceof Float || value instanceof Long) { + values[i] = ((Number) value).doubleValue(); + } else if (value instanceof CharSequence || value instanceof Character) { + values[i] = value.toString(); + } else { + throw new UnsupportedOperationException(); + } + + i++; + } + return new GeckoBundle(keys, values); + } + + private static Object fromJSONValue(final Object value) throws JSONException { + if (value == null || value == JSONObject.NULL) { + return null; + } else if (value instanceof JSONObject) { + return fromJSONObject((JSONObject) value); + } + if (value instanceof JSONArray) { + final JSONArray array = (JSONArray) value; + final int len = array.length(); + if (len == 0) { + return EMPTY_BOOLEAN_ARRAY; + } + Object out = null; + for (int i = 0; i < len; i++) { + final Object element = fromJSONValue(array.opt(i)); + if (element == null) { + continue; + } + if (out == null) { + Class<?> type = element.getClass(); + if (type == Boolean.class) { + type = boolean.class; + } else if (type == Integer.class) { + type = int.class; + } else if (type == Double.class) { + type = double.class; + } + out = Array.newInstance(type, len); + } + Array.set(out, i, element); + } + if (out == null) { + // Treat all-null arrays as String arrays. + return new String[len]; + } + return out; + } + if (value instanceof Boolean + || value instanceof Integer + || value instanceof Double + || value instanceof String) { + return value; + } + if (value instanceof Byte || value instanceof Short) { + return ((Number) value).intValue(); + } + if (value instanceof Float || value instanceof Long) { + return ((Number) value).doubleValue(); + } + return value.toString(); + } + + public static GeckoBundle fromJSONObject(final JSONObject obj) throws JSONException { + if (obj == null || obj == JSONObject.NULL) { + return null; + } + + final String[] keys = new String[obj.length()]; + final Object[] values = new Object[obj.length()]; + + final Iterator<String> iter = obj.keys(); + for (int i = 0; iter.hasNext(); i++) { + final String key = iter.next(); + keys[i] = key; + values[i] = fromJSONValue(obj.opt(key)); + } + return new GeckoBundle(keys, values); + } + + @Override // Parcelable + public int describeContents() { + return 0; + } + + @Override // Parcelable + public void writeToParcel(final Parcel dest, final int flags) { + final int len = mMap.size(); + dest.writeInt(len); + + for (int i = 0; i < len; i++) { + dest.writeString(mMap.keyAt(i)); + dest.writeValue(mMap.valueAt(i)); + } + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + public void readFromParcel(final Parcel source) { + final ClassLoader loader = getClass().getClassLoader(); + final int len = source.readInt(); + mMap.clear(); + mMap.ensureCapacity(len); + + for (int i = 0; i < len; i++) { + final String key = source.readString(); + Object val = source.readValue(loader); + + if (val instanceof Parcelable[]) { + final Parcelable[] array = (Parcelable[]) val; + val = Arrays.copyOf(array, array.length, GeckoBundle[].class); + } + + mMap.put(key, val); + } + } + + public static final Parcelable.Creator<GeckoBundle> CREATOR = + new Parcelable.Creator<GeckoBundle>() { + @Override + public GeckoBundle createFromParcel(final Parcel source) { + final GeckoBundle bundle = new GeckoBundle(0); + bundle.readFromParcel(source); + return bundle; + } + + @Override + public GeckoBundle[] newArray(final int size) { + return new GeckoBundle[size]; + } + }; +} 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 0000000000..ba317e9b48 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareCodecCapabilityUtils.java @@ -0,0 +1,255 @@ +/* -*- 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.media.MediaCodec; +import android.media.MediaCodecInfo; +import android.media.MediaCodecInfo.CodecCapabilities; +import android.media.MediaCodecList; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import org.mozilla.gecko.annotation.WrapForJNI; + +public final class HardwareCodecCapabilityUtils { + private static final String LOGTAG = "HardwareCodecCapability"; + + // 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.", "c2.exynos", "OMX.Intel." + }; + private static final String VP8_MIME_TYPE = "video/x-vnd.on2.vp8"; + // List of supported HW VP9 codecs. + private static final String[] supportedVp9HwCodecPrefixes = { + "OMX.qcom.", "OMX.Exynos.", "c2.exynos" + }; + private static final String VP9_MIME_TYPE = "video/x-vnd.on2.vp9"; + // List of supported HW H.264 codecs. + private static final String[] supportedH264HwCodecPrefixes = { + "OMX.qcom.", + "OMX.Intel.", + "OMX.Exynos.", + "c2.exynos", + "OMX.Nvidia", + "OMX.SEC.", + "OMX.IMG.", + "OMX.k3.", + "OMX.hisi.", + "OMX.TI.", + "OMX.MTK." + }; + private static final String H264_MIME_TYPE = "video/avc"; + // 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 + }; + private static final String[] adaptivePlaybackBlacklist = { + "GT-I9300", // S3 (I9300 / I9300I) + "SCH-I535", // S3 + "SGH-T999", // S3 (T-Mobile) + "SAMSUNG-SGH-T999", // S3 (T-Mobile) + "SGH-M919", // S4 + "GT-I9505", // S4 + "GT-I9515", // S4 + "SCH-R970", // S4 + "SGH-I337", // S4 + "SPH-L720", // S4 (Sprint) + "SAMSUNG-SGH-I337", // S4 + "GT-I9195", // S4 Mini + "300E5EV/300E4EV/270E5EV/270E4EV/2470EV/2470EE", + "LG-D605" // LG Optimus L9 II + }; + + private static MediaCodecInfo[] getCodecListWithOldAPI() { + int numCodecs = 0; + try { + numCodecs = MediaCodecList.getCodecCount(); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Failed to retrieve media codec count", e); + return new MediaCodecInfo[numCodecs]; + } + + final MediaCodecInfo[] codecList = new MediaCodecInfo[numCodecs]; + + for (int i = 0; i < numCodecs; ++i) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + codecList[i] = info; + } + + return codecList; + } + + @WrapForJNI + public static String[] getDecoderSupportedMimeTypes() { + final MediaCodecInfo[] codecList; + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + codecList = getCodecListWithOldAPI(); + } else { + final MediaCodecList list = new MediaCodecList(MediaCodecList.REGULAR_CODECS); + codecList = list.getCodecInfos(); + } + + final Set<String> supportedTypes = new HashSet<>(); + + for (final MediaCodecInfo codec : codecList) { + if (codec.isEncoder()) { + continue; + } + supportedTypes.addAll(Arrays.asList(codec.getSupportedTypes())); + } + + return supportedTypes.toArray(new String[0]); + } + + @WrapForJNI + public static boolean checkSupportsAdaptivePlayback( + final MediaCodec aCodec, final String aMimeType) { + // isFeatureSupported supported on API level >= 19. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT + || isAdaptivePlaybackBlacklisted(aMimeType)) { + return false; + } + + try { + final MediaCodecInfo info = aCodec.getCodecInfo(); + final MediaCodecInfo.CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType); + return capabilities != null + && capabilities.isFeatureSupported( + MediaCodecInfo.CodecCapabilities.FEATURE_AdaptivePlayback); + } catch (final IllegalArgumentException e) { + Log.e(LOGTAG, "Retrieve codec information failed", e); + } + return false; + } + + // See Bug1360626 and + // https://codereview.chromium.org/1869103002 for details. + private static boolean isAdaptivePlaybackBlacklisted(final String aMimeType) { + Log.d(LOGTAG, "The device ModelID is " + Build.MODEL); + if (!aMimeType.equals("video/avc") && !aMimeType.equals("video/avc1")) { + return false; + } + + if (!Build.MANUFACTURER.toLowerCase(Locale.ROOT).equals("samsung")) { + return false; + } + + for (final String model : adaptivePlaybackBlacklist) { + if (Build.MODEL.startsWith(model)) { + return true; + } + } + return false; + } + + public static boolean getHWCodecCapability(final String aMimeType, final boolean aIsEncoder) { + if (Build.VERSION.SDK_INT >= 20) { + for (int i = 0; i < MediaCodecList.getCodecCount(); ++i) { + final MediaCodecInfo info = MediaCodecList.getCodecInfoAt(i); + if (info.isEncoder() != aIsEncoder) { + continue; + } + String name = null; + for (final 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.d(LOGTAG, "Found candidate" + (aIsEncoder ? " encoder " : " decoder ") + name); + + // Check if this is supported codec. + final String[] hwList = getSupportedHWCodecPrefixes(aMimeType, aIsEncoder); + if (hwList == null) { + continue; + } + boolean supportedCodec = false; + for (final String codecPrefix : hwList) { + if (name.startsWith(codecPrefix)) { + supportedCodec = true; + break; + } + } + if (!supportedCodec) { + continue; + } + + // Check if codec supports either yuv420 or nv12. + final CodecCapabilities capabilities = info.getCapabilitiesForType(aMimeType); + for (final int colorFormat : capabilities.colorFormats) { + Log.v(LOGTAG, " Color: 0x" + Integer.toHexString(colorFormat)); + } + for (final int supportedColorFormat : supportedColorList) { + for (final int codecColorFormat : capabilities.colorFormats) { + if (codecColorFormat == supportedColorFormat) { + // Found supported HW Codec. + Log.d( + LOGTAG, + "Found target" + + (aIsEncoder ? " encoder " : " decoder ") + + name + + ". Color: 0x" + + Integer.toHexString(codecColorFormat)); + return true; + } + } + } + } + } + // No HW codec. + return false; + } + + private static String[] getSupportedHWCodecPrefixes( + final String aMimeType, final boolean aIsEncoder) { + if (aMimeType.equals(H264_MIME_TYPE)) { + return supportedH264HwCodecPrefixes; + } + if (aMimeType.equals(VP9_MIME_TYPE)) { + return supportedVp9HwCodecPrefixes; + } + if (aMimeType.equals(VP8_MIME_TYPE)) { + return aIsEncoder ? supportedVp8HwEncCodecPrefixes : supportedVp8HwDecCodecPrefixes; + } + return null; + } + + @WrapForJNI + public static boolean hasHWVP8(final boolean aIsEncoder) { + return getHWCodecCapability(VP8_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI + public static boolean hasHWVP9(final boolean aIsEncoder) { + return getHWCodecCapability(VP9_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI + public static boolean hasHWH264(final boolean aIsEncoder) { + return getHWCodecCapability(H264_MIME_TYPE, aIsEncoder); + } + + @WrapForJNI(calledFrom = "gecko") + public static boolean hasHWH264() { + return getHWCodecCapability(H264_MIME_TYPE, true) + && getHWCodecCapability(H264_MIME_TYPE, false); + } +} 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 0000000000..bab64b92d4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/HardwareUtils.java @@ -0,0 +1,46 @@ +/* -*- 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.content.res.Configuration; + +public final class HardwareUtils { + private static final String LOGTAG = "GeckoHardwareUtils"; + + private static volatile boolean sInited; + + // These are all set once, during init. + private static volatile boolean sIsLargeTablet; + private static volatile boolean sIsSmallTablet; + + private HardwareUtils() {} + + public static synchronized void init(final Context context) { + if (sInited) { + return; + } + + // Pre-populate common flags from the context. + final int screenLayoutSize = + context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK; + if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_XLARGE) { + sIsLargeTablet = true; + } else if (screenLayoutSize == Configuration.SCREENLAYOUT_SIZE_LARGE) { + sIsSmallTablet = true; + } + + sInited = true; + } + + public static boolean isTablet(final Context context) { + if (!sInited) { + init(context); + } + return sIsLargeTablet || sIsSmallTablet; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.java new file mode 100644 index 0000000000..96e5c7b311 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IXPCOMEventTarget.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.util; + +import java.util.concurrent.Executor; + +public interface IXPCOMEventTarget extends Executor { + public boolean isOnCurrentThread(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java new file mode 100644 index 0000000000..4ab330f182 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageDecoder.java @@ -0,0 +1,88 @@ +/* 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.Bitmap; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.geckoview.GeckoResult; + +/** Provides access to Gecko's Image processing library. */ +@AnyThread +public class ImageDecoder { + private static ImageDecoder instance; + + private ImageDecoder() {} + + public static ImageDecoder instance() { + if (instance == null) { + instance = new ImageDecoder(); + } + + return instance; + } + + @WrapForJNI(dispatchTo = "gecko", stubName = "Decode") + private static native void nativeDecode( + final String uri, final int desiredLength, GeckoResult<Bitmap> result); + + /** + * Fetches and decodes an image at the specified location. This method supports SVG, PNG, Bitmap + * and other formats supported by Gecko. + * + * @param uri location of the image. Can be either a remote https:// location, file:/// if the + * file is local or a resource://android/ if the file is located inside the APK. + * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to + * resource://android/assets/test.png. + * @return A {@link GeckoResult} to the decoded image. + */ + @NonNull + public GeckoResult<Bitmap> decode(final @NonNull String uri) { + return decode(uri, 0); + } + + /** + * Fetches and decodes an image at the specified location and resizes it to the desired length. + * This method supports SVG, PNG, Bitmap and other formats supported by Gecko. + * + * <p>Note: The final size might differ slightly from the requested output. + * + * @param uri location of the image. Can be either a remote https:// location, file:/// if the + * file is local or a resource://android/ if the file is located inside the APK. + * <p>e.g. if the image file is locate at /assets/test.png inside the apk, set the uri to + * resource://android/assets/test.png. + * @param desiredLength Longest size for the image in device pixel units. The resulting image + * might be slightly different if the image cannot be resized efficiently. If desiredLength is + * 0 then the image will be decoded to its natural size. + * @return A {@link GeckoResult} to the decoded image. + */ + @NonNull + public GeckoResult<Bitmap> decode(final @NonNull String uri, final int desiredLength) { + if (uri == null) { + throw new IllegalArgumentException("Uri cannot be null"); + } + + final GeckoResult<Bitmap> result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeDecode(uri, desiredLength, result); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + this, + "nativeDecode", + String.class, + uri, + int.class, + desiredLength, + GeckoResult.class, + result); + } + + return result; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java new file mode 100644 index 0000000000..d57147f363 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ImageResource.java @@ -0,0 +1,334 @@ +/* -*- 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.util; + +import android.graphics.Bitmap; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.mozilla.geckoview.GeckoResult; + +/** + * Represents an Web API image resource as used in web app manifests and media session metadata. + * + * @see <a href="https://www.w3.org/TR/image-resource">Image Resource</a> + */ +@AnyThread +public class ImageResource { + private static final String LOGTAG = "ImageResource"; + private static final boolean DEBUG = false; + + /** Represents the size of an image resource option. */ + public static class Size { + /** The width in pixels. */ + public final int width; + + /** The height in pixels. */ + public final int height; + + /** + * Size contructor. + * + * @param width The width in pixels. + * @param height The height in pixels. + */ + public Size(final int width, final int height) { + this.width = width; + this.height = height; + } + } + + /** The URI of the image resource. */ + public final @NonNull String src; + + /** The MIME type of the image resource. */ + public final @Nullable String type; + + /** A {@link Size} array of supported images sizes. */ + public final @Nullable Size[] sizes; + + /** + * ImageResource constructor. + * + * @param src The URI string of the image resource. + * @param type The MIME type of the image resource. + * @param sizes The supported images {@link Size} array. + */ + public ImageResource( + final @NonNull String src, final @Nullable String type, final @Nullable Size[] sizes) { + this.src = src.toLowerCase(Locale.ROOT); + this.type = type != null ? type.toLowerCase(Locale.ROOT) : null; + this.sizes = sizes; + } + + /** + * ImageResource constructor. + * + * @param src The URI string of the image resource. + * @param type The MIME type of the image resource. + * @param sizes The supported images sizes string. + * @see <a href="https://html.spec.whatwg.org/multipage/semantics.html#dom-link-sizes">Attribute + * spec for sizes</a> + */ + public ImageResource( + final @NonNull String src, final @Nullable String type, final @Nullable String sizes) { + this(src, type, parseSizes(sizes)); + } + + private static @Nullable Size[] parseSizes(final @Nullable String sizesStr) { + if (sizesStr == null || sizesStr.isEmpty()) { + return null; + } + + final String[] sizesStrs = sizesStr.toLowerCase(Locale.ROOT).split(" "); + final List<Size> sizes = new ArrayList<Size>(); + + for (final String sizeStr : sizesStrs) { + if (sizesStr.equals("any")) { + // 0-width size will always be favored. + sizes.add(new Size(0, 0)); + continue; + } + final String[] widthHeight = sizeStr.split("x"); + if (widthHeight.length != 2) { + // Not spec-compliant size. + continue; + } + try { + sizes.add(new Size(Integer.valueOf(widthHeight[0]), Integer.valueOf(widthHeight[1]))); + } catch (final NumberFormatException e) { + Log.e(LOGTAG, "Invalid image resource size", e); + } + } + if (sizes.isEmpty()) { + return null; + } + return sizes.toArray(new Size[0]); + } + + public static @NonNull ImageResource fromBundle(final GeckoBundle bundle) { + return new ImageResource( + bundle.getString("src"), bundle.getString("type"), bundle.getString("sizes")); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("ImageResource {"); + builder + .append("src=") + .append(src) + .append("type=") + .append(type) + .append("sizes=") + .append(sizes) + .append("}"); + return builder.toString(); + } + + /** + * Get the best version of this image for size <code>size</code>. Embedders are encouraged to + * cache the result of this method keyed with this instance. + * + * @param size pixel size at which this image will be displayed at. + * @return A {@link GeckoResult} that resolves to the bitmap when ready. + */ + @NonNull + public GeckoResult<Bitmap> getBitmap(final int size) { + return ImageDecoder.instance().decode(src, size); + } + + /** + * Represents a collection of {@link ImageResource} options. Image resources are often used in a + * collection to provide multiple image options for various sizes. This data structure can be used + * to retrieve the best image resource for any given target image size. + */ + public static class Collection { + private static class SizeIndexPair { + public final int width; + public final int idx; + + public SizeIndexPair(final int width, final int idx) { + this.width = width; + this.idx = idx; + } + } + + // The individual image resources, usually each with a unique src. + private final List<ImageResource> mImages; + + // A sorted size-index list. The list is sorted based on the supported + // sizes of the images in ascending order. + private final List<SizeIndexPair> mSizeIndex; + + /* package */ Collection() { + mImages = new ArrayList<>(); + mSizeIndex = new ArrayList<>(); + } + + /** Builder class for the construction of a {@link Collection}. */ + public static class Builder { + final Collection mCollection; + + public Builder() { + mCollection = new Collection(); + } + + /** + * Add an image resource to the collection. + * + * @param image The {@link ImageResource} to be added. + * @return This builder instance. + */ + public @NonNull Builder add(final ImageResource image) { + final int index = mCollection.mImages.size(); + + if (image.sizes == null) { + // Null-sizes are handled the same as `any`. + mCollection.mSizeIndex.add(new SizeIndexPair(0, index)); + } else { + for (final Size size : image.sizes) { + mCollection.mSizeIndex.add(new SizeIndexPair(size.width, index)); + } + } + mCollection.mImages.add(image); + return this; + } + + /** + * Finalize the collection. + * + * @return The final collection. + */ + public @NonNull Collection build() { + Collections.sort(mCollection.mSizeIndex, (a, b) -> Integer.compare(a.width, b.width)); + return mCollection; + } + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("ImageResource.Collection {"); + builder.append("images=["); + + for (final ImageResource image : mImages) { + builder.append(image).append(", "); + } + builder.append("]}"); + return builder.toString(); + } + + /** + * Returns the best suited {@link ImageResource} for the given size. This is usually determined + * based on the minimal difference between the given size and one of the supported widths of an + * image resource. + * + * @param size The target size for the image in pixels. + * @return The best {@link ImageResource} for the given size from this collection. + */ + public @Nullable ImageResource getBest(final int size) { + if (mSizeIndex.isEmpty()) { + return null; + } + int bestMatchIdx = mSizeIndex.get(0).idx; + int lastDiff = size; + for (final SizeIndexPair sizeIndex : mSizeIndex) { + final int diff = Math.abs(sizeIndex.width - size); + if (lastDiff <= diff) { + // With increasing widths, the difference can only grow now. + // 0-width means "any", so we're finished at the first + // entry. + break; + } + lastDiff = diff; + bestMatchIdx = sizeIndex.idx; + } + return mImages.get(bestMatchIdx); + } + + /** + * Get the best version of this image for size <code>size</code>. Embedders are encouraged to + * cache the result of this method keyed with this instance. + * + * @param size pixel size at which this image will be displayed at. + * @return A {@link GeckoResult} that resolves to the bitmap when ready. + */ + @NonNull + public GeckoResult<Bitmap> getBitmap(final int size) { + final ImageResource image = getBest(size); + if (image == null) { + return GeckoResult.fromValue(null); + } + return image.getBitmap(size); + } + + public static Collection fromSizeSrcBundle(final GeckoBundle bundle) { + final Builder builder = new Builder(); + + for (final String key : bundle.keys()) { + final Integer intKey = Integer.valueOf(key); + if (intKey == null) { + Log.e(LOGTAG, "Non-integer image key: " + intKey); + + if (DEBUG) { + throw new RuntimeException("Non-integer image key: " + key); + } + continue; + } + + final String src = getImageValue(bundle.get(key)); + if (src != null) { + // Given the bundle structure, we don't have insight on + // individual image resources so we have to create an + // instance for each size entry. + final ImageResource image = + new ImageResource(src, null, new Size[] {new Size(intKey, intKey)}); + builder.add(image); + } + } + return builder.build(); + } + + private static String getImageValue(final Object value) { + // The image value can either be an object containing images for + // each theme... + if (value instanceof GeckoBundle) { + // We don't support theme_images yet, so let's just return the + // default value. + final GeckoBundle themeImages = (GeckoBundle) value; + final Object defaultImages = themeImages.get("default"); + + if (!(defaultImages instanceof String)) { + if (DEBUG) { + throw new RuntimeException("Unexpected themed_icon value."); + } + Log.e(LOGTAG, "Unexpected themed_icon value."); + return null; + } + + return (String) defaultImages; + } + + // ... or just a URL. + if (value instanceof String) { + return (String) value; + } + + // We never expect it to be something else, so let's error out here. + if (DEBUG) { + throw new RuntimeException("Unexpected image value: " + value); + } + + Log.e(LOGTAG, "Unexpected image value."); + return null; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java new file mode 100644 index 0000000000..e0a0d924a9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/InputDeviceUtils.java @@ -0,0 +1,20 @@ +/* -*- 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.view.InputDevice; + +public class InputDeviceUtils { + public static boolean isPointerTypeDevice(final InputDevice inputDevice) { + final int sources = inputDevice.getSources(); + return (sources + & (InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL)) + != 0; + } +} 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 0000000000..1255f238f7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/IntentUtils.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 android.annotation.TargetApi; +import android.content.Intent; +import android.net.Uri; +import java.net.URISyntaxException; +import java.util.Locale; + +/** Utilities for Intents. */ +public class IntentUtils { + private IntentUtils() {} + + /** + * Return a Uri instance which is equivalent to uri, but with a guaranteed-lowercase scheme as if + * the API level 16 method Uri.normalizeScheme had been called. + * + * @param uri The URI string to normalize. + * @return The corresponding normalized Uri. + */ + private static Uri normalizeUriScheme(final Uri uri) { + final String scheme = uri.getScheme(); + final String lower = scheme.toLowerCase(Locale.ROOT); + if (lower.equals(scheme)) { + return uri; + } + + // Otherwise, return a new URI with a normalized scheme. + return uri.buildUpon().scheme(lower).build(); + } + + /** + * Return a normalized Uri instance that corresponds to the given URI string with cross-API-level + * compatibility. + * + * @param aUri The URI string to normalize. + * @return The corresponding normalized Uri. + */ + public static Uri normalizeUri(final String aUri) { + final Uri normUri = + normalizeUriScheme( + aUri.indexOf(':') >= 0 ? Uri.parse(aUri) : new Uri.Builder().scheme(aUri).build()); + return normUri; + } + + public static boolean isUriSafeForScheme(final String aUri) { + return isUriSafeForScheme(normalizeUri(aUri)); + } + + /** + * Verify whether the given URI is considered safe to load in respect to its scheme. Unsafe URIs + * should be blocked from further handling. + * + * @param aUri The URI instance to test. + * @return Whether the provided URI is considered safe in respect to its scheme. + */ + public static boolean isUriSafeForScheme(final Uri aUri) { + final String scheme = aUri.getScheme(); + if ("tel".equals(scheme) || "sms".equals(scheme)) { + // 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 number = aUri.getSchemeSpecificPart(); + if (number.contains("#") || number.contains("*") || aUri.getFragment() != null) { + return false; + } + } + + if (("intent".equals(scheme) || "android-app".equals(scheme))) { + // Bug 1356893 - Rject intents with file data schemes. + return getSafeIntent(aUri) != null; + } + + return true; + } + + /** + * Create a safe intent for the given URI. Intents with file data schemes are considered unsafe. + * + * @param aUri The URI for the intent. + * @return A safe intent for the given URI or null if URI is considered unsafe. + */ + public static Intent getSafeIntent(final Uri aUri) { + final Intent intent; + try { + intent = Intent.parseUri(aUri.toString(), 0); + } catch (final URISyntaxException e) { + return null; + } + + final Uri data = intent.getData(); + if (data != null && "file".equals(normalizeUriScheme(data).getScheme())) { + return null; + } + + // Only open applications which can accept arbitrary data from a browser. + intent.addCategory(Intent.CATEGORY_BROWSABLE); + + // Prevent site from explicitly opening our internal activities, + // which can leak data. + intent.setComponent(null); + nullIntentSelector(intent); + + return intent; + } + + // We create a separate method to better encapsulate the @TargetApi use. + @TargetApi(15) + private static void nullIntentSelector(final Intent intent) { + intent.setSelector(null); + } +} 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 0000000000..b8f15c04e3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/NetworkUtils.java @@ -0,0 +1,168 @@ +/* -*- 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.ConnectivityManager; +import android.net.NetworkInfo; +import android.telephony.TelephonyManager; + +public class NetworkUtils { + /* + * Keep the below constants in sync with + * http://searchfox.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(final String value) { + this.value = value; + } + } + + /* + * Keep the below constants in sync with + * http://searchfox.org/mozilla-central/source/netwerk/base/nsINetworkLinkService.idl + */ + public enum NetworkStatus { + UP("up"), + DOWN("down"), + UNKNOWN("unknown"); + + public final String value; + + NetworkStatus(final 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(final int value) { + this.value = value; + } + } + + public static boolean isConnected(final 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( + final 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(final 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(final ConnectivityManager connectivityManager) { + if (connectivityManager == null) { + return NetworkStatus.UNKNOWN; + } + + if (isConnected(connectivityManager)) { + return NetworkStatus.UP; + } + return NetworkStatus.DOWN; + } + + private static ConnectionSubType getGenericMobileSubtype(final 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/ProxySelector.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java new file mode 100644 index 0000000000..2fb4015f41 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ProxySelector.java @@ -0,0 +1,149 @@ +/* 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.text.TextUtils; +import androidx.annotation.Nullable; +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(final URI uri) throws IOException { + final java.net.ProxySelector ps = java.net.ProxySelector.getDefault(); + Proxy proxy = Proxy.NO_PROXY; + if (ps != null) { + final 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(final String scheme, final 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( + final String hostKey, final String portKey, final Proxy.Type type, final 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(final String key, final int defaultValue) { + final String string = System.getProperty(key); + if (string != null) { + try { + return Integer.parseInt(string); + } catch (final NumberFormatException ignored) { + } + } + return defaultValue; + } + + /** + * Returns true if the {@code nonProxyHosts} system property pattern exists and matches {@code + * host}. + */ + private boolean isNonProxyHost(final String host, final String nonProxyHosts) { + if (host == null || nonProxyHosts == null) { + return false; + } + + // construct pattern + final StringBuilder patternBuilder = new StringBuilder(); + for (int i = 0; i < nonProxyHosts.length(); i++) { + final 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. + final String pattern = patternBuilder.toString(); + return host.matches(pattern); + } +} 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 0000000000..00625800c9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/ThreadUtils.java @@ -0,0 +1,145 @@ +/* -*- 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.os.Handler; +import android.os.Looper; +import android.util.Log; +import org.mozilla.gecko.annotation.RobocopTarget; + +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 enum AssertBehavior { + NONE, + THROW, + } + + private static final Thread sUiThread = Looper.getMainLooper().getThread(); + private static final Handler sUiHandler = new Handler(Looper.getMainLooper()); + + // 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; + + public static Thread getUiThread() { + return sUiThread; + } + + public static Handler getUiHandler() { + return sUiHandler; + } + + /** + * Runs the provided runnable on the UI thread. If this method is called on the UI thread the + * runnable will be executed synchronously. + * + * @param runnable the runnable to be executed. + */ + public static void runOnUiThread(final Runnable runnable) { + // We're on the UI thread already, let's just run this + if (isOnUiThread()) { + runnable.run(); + return; + } + + postToUiThread(runnable); + } + + public static void postToUiThread(final Runnable runnable) { + sUiHandler.post(runnable); + } + + public static void postToUiThreadDelayed(final Runnable runnable, final long delayMillis) { + sUiHandler.postDelayed(runnable, delayMillis); + } + + public static void removeUiThreadCallbacks(final Runnable runnable) { + sUiHandler.removeCallbacks(runnable); + } + + public static Handler getBackgroundHandler() { + return GeckoBackgroundThread.getHandler(); + } + + public static void postToBackgroundThread(final Runnable runnable) { + GeckoBackgroundThread.post(runnable); + } + + public static void assertOnUiThread(final AssertBehavior assertBehavior) { + assertOnThread(getUiThread(), assertBehavior); + } + + public static void assertOnUiThread() { + assertOnThread(getUiThread(), AssertBehavior.THROW); + } + + @RobocopTarget + public static void assertOnGeckoThread() { + assertOnThread(sGeckoThread, AssertBehavior.THROW); + } + + public static void assertOnThread(final Thread expectedThread, final AssertBehavior behavior) { + assertOnThreadComparison(expectedThread, behavior, true); + } + + private static void assertOnThreadComparison( + final Thread expectedThread, final AssertBehavior behavior, final 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 isOnUiThread() { + return isOnThread(getUiThread()); + } + + @RobocopTarget + public static boolean isOnThread(final Thread thread) { + return (Thread.currentThread().getId() == thread.getId()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja new file mode 100644 index 0000000000..f704bbc775 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMError.jinja @@ -0,0 +1,38 @@ +/* -*- Mode: Java; c-basic-offset: 2; 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; + +public final class XPCOMError { + /** Check if the error code corresponds to a failure */ + public static boolean failed(long err) { + return (err & 0x80000000L) != 0; + } + + /** Check if the error code corresponds to a failure */ + public static boolean succeeded(long err) { + return !failed(err); + } + + /** Extract the error code part of the error message */ + public static int getErrorCode(long err) { + return (int)(err & 0xffffL); + } + + /** Extract the error module part of the error message */ + public static int getErrorModule(long err) { + return (int)(((err >> 16) - NS_ERROR_MODULE_BASE_OFFSET) & 0x1fffL); + } + + public static final int NS_ERROR_MODULE_BASE_OFFSET = {{ MODULE_BASE_OFFSET }}; + +{% for mod, val in modules %} + public static final int NS_ERROR_MODULE_{{ mod }} = {{ val }}; +{% endfor %} + +{% for error, val in errors %} + public static final long {{ error }} = 0x{{ "%X" % val }}L; +{% endfor %} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java new file mode 100644 index 0000000000..31eac71a66 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/gecko/util/XPCOMEventTarget.java @@ -0,0 +1,170 @@ +/* -*- 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 androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.geckoview.BuildConfig; + +/** + * Wrapper for nsIEventTarget, enabling seamless dispatch of java runnables to Gecko event queues. + */ +@WrapForJNI +public final class XPCOMEventTarget extends JNIObject implements IXPCOMEventTarget { + @Override + public void execute(final Runnable runnable) { + dispatchNative(new JNIRunnable(runnable)); + } + + public static synchronized IXPCOMEventTarget mainThread() { + if (mMainThread == null) { + mMainThread = new AsyncProxy("main"); + } + return mMainThread; + } + + private static IXPCOMEventTarget mMainThread = null; + + public static synchronized IXPCOMEventTarget launcherThread() { + if (mLauncherThread == null) { + mLauncherThread = new AsyncProxy("launcher"); + } + return mLauncherThread; + } + + private static IXPCOMEventTarget mLauncherThread = null; + + /** + * Runs the provided runnable on the launcher thread. If this method is called from the launcher + * thread itself, the runnable will be executed immediately and synchronously. + */ + public static void runOnLauncherThread(@NonNull final Runnable runnable) { + final IXPCOMEventTarget launcherThread = launcherThread(); + if (launcherThread.isOnCurrentThread()) { + // We're already on the launcher thread, just execute the runnable + runnable.run(); + return; + } + + launcherThread.execute(runnable); + } + + public static void assertOnLauncherThread() { + if (BuildConfig.DEBUG_BUILD && !launcherThread().isOnCurrentThread()) { + throw new AssertionError("Expected to be running on XPCOM launcher thread"); + } + } + + public static void assertNotOnLauncherThread() { + if (BuildConfig.DEBUG_BUILD && launcherThread().isOnCurrentThread()) { + throw new AssertionError("Expected to not be running on XPCOM launcher thread"); + } + } + + private static synchronized IXPCOMEventTarget getTarget(final String name) { + if (name.equals("launcher")) { + return mLauncherThread; + } else if (name.equals("main")) { + return mMainThread; + } else { + throw new RuntimeException("Attempt to assign to unknown thread named " + name); + } + } + + @WrapForJNI + private static synchronized void setTarget(final String name, final XPCOMEventTarget target) { + if (name.equals("main")) { + mMainThread = target; + } else if (name.equals("launcher")) { + mLauncherThread = target; + } else { + throw new RuntimeException("Attempt to assign to unknown thread named " + name); + } + + // Ensure that we see the right name in the Java debugger. We don't do this for mMainThread + // because its name was already set (in this context, "main" is the GeckoThread). + if (mMainThread != target) { + target.execute( + () -> { + Thread.currentThread().setName(name); + }); + } + } + + @Override + public native boolean isOnCurrentThread(); + + private native void dispatchNative(final JNIRunnable runnable); + + @WrapForJNI + private static synchronized void resolveAndDispatch(final String name, final Runnable runnable) { + getTarget(name).execute(runnable); + } + + private static native void resolveAndDispatchNative(final String name, final Runnable runnable); + + @Override + protected native void disposeNative(); + + @WrapForJNI + private static final class JNIRunnable { + JNIRunnable(final Runnable inner) { + mInner = inner; + } + + @WrapForJNI + void run() { + mInner.run(); + } + + private Runnable mInner; + } + + private static final class AsyncProxy implements IXPCOMEventTarget { + private String mTargetName; + + public AsyncProxy(final String targetName) { + mTargetName = targetName; + } + + @Override + public void execute(final Runnable runnable) { + final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName); + + if (target != null && target instanceof XPCOMEventTarget) { + target.execute(runnable); + return; + } + + GeckoThread.queueNativeCallUntil( + GeckoThread.State.JNI_READY, + XPCOMEventTarget.class, + "resolveAndDispatchNative", + String.class, + mTargetName, + Runnable.class, + runnable); + } + + @Override + public boolean isOnCurrentThread() { + final IXPCOMEventTarget target = XPCOMEventTarget.getTarget(mTargetName); + + // If target is not yet a XPCOMEventTarget then JNI is not + // initialized yet. If JNI is not initialized yet, then we cannot + // possibly be running on a target with an XPCOMEventTarget. + if (target == null || !(target instanceof XPCOMEventTarget)) { + return false; + } + + // Otherwise we have a real XPCOMEventTarget, so we can delegate + // this call to it. + return target.isOnCurrentThread(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java new file mode 100644 index 0000000000..f8342cbfa7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/AllowOrDeny.java @@ -0,0 +1,16 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; + +/** This represents a decision to allow or deny a request. */ +@AnyThread +public enum AllowOrDeny { + ALLOW, + DENY; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java new file mode 100644 index 0000000000..e8a004df17 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autocomplete.java @@ -0,0 +1,1445 @@ +/* -*- 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.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * The Autocomplete API provides a way to leverage Gecko's input form handling for autocompletion. + * + * <p>The API is split into two parts: 1. Storage-level delegates. 2. User-prompt delegates. + * + * <p>The storage-level delegates connect Gecko mechanics to the app's storage, e.g., retrieving and + * storing of login entries. + * + * <p>The user-prompt delegates propagate decisions to the app that could require user choice, e.g., + * saving or updating of login entries or the selection of a login entry out of multiple options. + * + * <p>Throughout the documentation, we will refer to the filling out of input forms using two terms: + * 1. Autofill: automatic filling without user interaction. 2. Autocomplete: semi-automatic filling + * that requires user prompting for the selection. + * + * <h2>Examples</h2> + * + * <h3>Autocomplete/Fetch API</h3> + * + * <p>GeckoView loads <code>https://example.com</code> which contains (for the purpose of this + * example) elements resembling a login form, e.g., + * + * <pre><code> + * <form> + * <input type="text" placeholder="username"> + * <input type="password" placeholder="password"> + * <input type="submit" value="submit"> + * </form> + * </code></pre> + * + * <p>With the document parsed and the login input fields identified, GeckoView dispatches a <code> + * StorageDelegate.onLoginFetch("example.com")</code> request to fetch logins for the + * given domain. + * + * <p>Based on the provided login entries, GeckoView will attempt to autofill the login input + * fields, if there is only one suitable login entry option. + * + * <p>In the case of multiple valid login entry options, GeckoView dispatches a <code> + * GeckoSession.PromptDelegate.onLoginSelect</code> request, which allows for user-choice + * delegation. + * + * <p>Based on the returned login entries, GeckoView will attempt to autofill/autocomplete the login + * input fields. + * + * <h3>Update API</h3> + * + * <p>When the user submits some login input fields, GeckoView dispatches another <code> + * StorageDelegate.onLoginFetch("example.com")</code> request to check whether the + * submitted login exists or whether it's a new or updated login entry. + * + * <p>If the submitted login is already contained as-is in the collection returned by <code> + * onLoginFetch</code>, then GeckoView dispatches <code>StorageDelegate.onLoginUsed</code> with the + * submitted login entry. + * + * <p>If the submitted login is a new or updated entry, GeckoView dispatches a sequence of requests + * to save/update the login entry, see the Save API example. + * + * <h3>Save API</h3> + * + * <p>The user enters new or updated (password) login credentials in some login input fields and + * submits explicitely (submit action) or by navigation. GeckoView identifies the entered + * credentials and dispatches a <code>GeckoSession.PromptDelegate.onLoginSave(session, request) + * </code> with the provided credentials. + * + * <p>The app may dismiss the prompt request via <code> + * return GeckoResult.fromValue(prompt.dismiss())</code> which terminates this saving request, or + * confirm it via <code>return GeckoResult.fromValue(prompt.confirm(login))</code> where <code>login + * </code> either holds the credentials originally provided by the prompt request (<code> + * prompt.logins[0]</code>) or a new or modified login entry. + * + * <p>The login entry returned in a confirmed save prompt is used to request for saving in the + * runtime delegate via <code>StorageDelegate.onLoginSave(login)</code>. If the app has already + * stored the entry during the prompt request handling, it may ignore this storage saving request. + * <br> + * + * @see GeckoRuntime#setAutocompleteStorageDelegate <br> + * @see GeckoSession#setPromptDelegate <br> + * @see GeckoSession.PromptDelegate#onLoginSave <br> + * @see GeckoSession.PromptDelegate#onLoginSelect + */ +public class Autocomplete { + private static final String LOGTAG = "Autocomplete"; + private static final boolean DEBUG = false; + + protected Autocomplete() {} + + /** Holds credit card information for a specific entry. */ + public static class CreditCard { + private static final String GUID_KEY = "guid"; + private static final String NAME_KEY = "name"; + private static final String NUMBER_KEY = "number"; + private static final String EXP_MONTH_KEY = "expMonth"; + private static final String EXP_YEAR_KEY = "expYear"; + + /** The unique identifier for this login entry. */ + public final @Nullable String guid; + + /** The full name as it appears on the credit card. */ + public final @NonNull String name; + + /** The credit card number. */ + public final @NonNull String number; + + /** The expiration month. */ + public final @NonNull String expirationMonth; + + /** The expiration year. */ + public final @NonNull String expirationYear; + + // For tests only. + @AnyThread + protected CreditCard() { + guid = null; + name = ""; + number = ""; + expirationMonth = ""; + expirationYear = ""; + } + + @AnyThread + /* package */ CreditCard(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + name = bundle.getString(NAME_KEY, ""); + number = bundle.getString(NUMBER_KEY, ""); + expirationMonth = bundle.getString(EXP_MONTH_KEY, ""); + expirationYear = bundle.getString(EXP_YEAR_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("CreditCard {"); + builder + .append("guid=") + .append(guid) + .append(", name=") + .append(name) + .append(", number=") + .append(number) + .append(", expirationMonth=") + .append(expirationMonth) + .append(", expirationYear=") + .append(expirationYear) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(7); + bundle.putString(GUID_KEY, guid); + bundle.putString(NAME_KEY, name); + bundle.putString(NUMBER_KEY, number); + if (expirationMonth != null) { + bundle.putString(EXP_MONTH_KEY, expirationMonth); + } + if (expirationYear != null) { + bundle.putString(EXP_YEAR_KEY, expirationYear); + } + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(7); + } + + /** + * Finalize the {@link CreditCard} instance. + * + * @return The {@link CreditCard} instance. + */ + @AnyThread + public @NonNull CreditCard build() { + return new CreditCard(mBundle); + } + + /** + * Set the unique identifier for this credit card entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the name for this credit card entry. + * + * @param name The full name as it appears on the credit card. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder name(final @Nullable String name) { + mBundle.putString(NAME_KEY, name); + return this; + } + + /** + * Set the number for this credit card entry. + * + * @param number The credit card number string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder number(final @Nullable String number) { + mBundle.putString(NUMBER_KEY, number); + return this; + } + + /** + * Set the expiration month for this credit card entry. + * + * @param expMonth The expiration month string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder expirationMonth(final @Nullable String expMonth) { + mBundle.putString(EXP_MONTH_KEY, expMonth); + return this; + } + + /** + * Set the expiration year for this credit card entry. + * + * @param expYear The expiration year string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder expirationYear(final @Nullable String expYear) { + mBundle.putString(EXP_YEAR_KEY, expYear); + return this; + } + } + } + + /** Holds address information for a specific entry. */ + public static class Address { + private static final String GUID_KEY = "guid"; + private static final String NAME_KEY = "name"; + private static final String GIVEN_NAME_KEY = "givenName"; + private static final String ADDITIONAL_NAME_KEY = "additionalName"; + private static final String FAMILY_NAME_KEY = "familyName"; + private static final String ORGANIZATION_KEY = "organization"; + private static final String STREET_ADDRESS_KEY = "streetAddress"; + private static final String ADDRESS_LEVEL1_KEY = "addressLevel1"; + private static final String ADDRESS_LEVEL2_KEY = "addressLevel2"; + private static final String ADDRESS_LEVEL3_KEY = "addressLevel3"; + private static final String POSTAL_CODE_KEY = "postalCode"; + private static final String COUNTRY_KEY = "country"; + private static final String TEL_KEY = "tel"; + private static final String EMAIL_KEY = "email"; + private static final byte bundleCapacity = 14; + + /** The unique identifier for this address entry. */ + public final @Nullable String guid; + + /** The full name. */ + public final @NonNull String name; + + /** The given (first) name. */ + public final @NonNull String givenName; + + /** An additional name, if available. */ + public final @NonNull String additionalName; + + /** The family name. */ + public final @NonNull String familyName; + + /** The name of the company, if applicable. */ + public final @NonNull String organization; + + /** The (multiline) street address. */ + public final @NonNull String streetAddress; + + /** The level 1 (province) address. Note: Only use if streetAddress is not provided. */ + public final @NonNull String addressLevel1; + + /** The level 2 (city/town) address. Note: Only use if streetAddress is not provided. */ + public final @NonNull String addressLevel2; + + /** + * The level 3 (suburb/sublocality) address. Note: Only use if streetAddress is not provided. + */ + public final @NonNull String addressLevel3; + + /** The postal code. */ + public final @NonNull String postalCode; + + /** The country string in ISO 3166. */ + public final @NonNull String country; + + /** The telephone number string. */ + public final @NonNull String tel; + + /** The email address. */ + public final @NonNull String email; + + // For tests only. + @AnyThread + protected Address() { + guid = null; + name = ""; + givenName = ""; + additionalName = ""; + familyName = ""; + organization = ""; + streetAddress = ""; + addressLevel1 = ""; + addressLevel2 = ""; + addressLevel3 = ""; + postalCode = ""; + country = ""; + tel = ""; + email = ""; + } + + @AnyThread + /* package */ Address(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + name = bundle.getString(NAME_KEY, ""); + givenName = bundle.getString(GIVEN_NAME_KEY, ""); + additionalName = bundle.getString(ADDITIONAL_NAME_KEY, ""); + familyName = bundle.getString(FAMILY_NAME_KEY, ""); + organization = bundle.getString(ORGANIZATION_KEY, ""); + streetAddress = bundle.getString(STREET_ADDRESS_KEY, ""); + addressLevel1 = bundle.getString(ADDRESS_LEVEL1_KEY, ""); + addressLevel2 = bundle.getString(ADDRESS_LEVEL2_KEY, ""); + addressLevel3 = bundle.getString(ADDRESS_LEVEL3_KEY, ""); + postalCode = bundle.getString(POSTAL_CODE_KEY, ""); + country = bundle.getString(COUNTRY_KEY, ""); + tel = bundle.getString(TEL_KEY, ""); + email = bundle.getString(EMAIL_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("Address {"); + builder + .append("guid=") + .append(guid) + .append(", givenName=") + .append(givenName) + .append(", additionalName=") + .append(additionalName) + .append(", familyName=") + .append(familyName) + .append(", organization=") + .append(organization) + .append(", streetAddress=") + .append(streetAddress) + .append(", addressLevel1=") + .append(addressLevel1) + .append(", addressLevel2=") + .append(addressLevel2) + .append(", addressLevel3=") + .append(addressLevel3) + .append(", postalCode=") + .append(postalCode) + .append(", country=") + .append(country) + .append(", tel=") + .append(tel) + .append(", email=") + .append(email) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(bundleCapacity); + bundle.putString(GUID_KEY, guid); + bundle.putString(NAME_KEY, name); + bundle.putString(GIVEN_NAME_KEY, givenName); + bundle.putString(ADDITIONAL_NAME_KEY, additionalName); + bundle.putString(FAMILY_NAME_KEY, familyName); + bundle.putString(ORGANIZATION_KEY, organization); + bundle.putString(STREET_ADDRESS_KEY, streetAddress); + bundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1); + bundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2); + bundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3); + bundle.putString(POSTAL_CODE_KEY, postalCode); + bundle.putString(COUNTRY_KEY, country); + bundle.putString(TEL_KEY, tel); + bundle.putString(EMAIL_KEY, email); + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(bundleCapacity); + } + + /** + * Finalize the {@link Address} instance. + * + * @return The {@link Address} instance. + */ + @AnyThread + public @NonNull Address build() { + return new Address(mBundle); + } + + /** + * Set the unique identifier for this address entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the full name for this address entry. + * + * @param name The full name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder name(final @Nullable String name) { + mBundle.putString(NAME_KEY, name); + return this; + } + + /** + * Set the given name for this address entry. + * + * @param givenName The given name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder givenName(final @Nullable String givenName) { + mBundle.putString(GIVEN_NAME_KEY, givenName); + return this; + } + + /** + * Set the additional name for this address entry. + * + * @param additionalName The additional name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder additionalName(final @Nullable String additionalName) { + mBundle.putString(ADDITIONAL_NAME_KEY, additionalName); + return this; + } + + /** + * Set the family name for this address entry. + * + * @param familyName The family name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder familyName(final @Nullable String familyName) { + mBundle.putString(FAMILY_NAME_KEY, familyName); + return this; + } + + /** + * Set the company name for this address entry. + * + * @param organization The company name string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder organization(final @Nullable String organization) { + mBundle.putString(ORGANIZATION_KEY, organization); + return this; + } + + /** + * Set the street address for this address entry. + * + * @param streetAddress The street address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder streetAddress(final @Nullable String streetAddress) { + mBundle.putString(STREET_ADDRESS_KEY, streetAddress); + return this; + } + + /** + * Set the level 1 address for this address entry. + * + * @param addressLevel1 The level 1 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel1(final @Nullable String addressLevel1) { + mBundle.putString(ADDRESS_LEVEL1_KEY, addressLevel1); + return this; + } + + /** + * Set the level 2 address for this address entry. + * + * @param addressLevel2 The level 2 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel2(final @Nullable String addressLevel2) { + mBundle.putString(ADDRESS_LEVEL2_KEY, addressLevel2); + return this; + } + + /** + * Set the level 3 address for this address entry. + * + * @param addressLevel3 The level 3 address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder addressLevel3(final @Nullable String addressLevel3) { + mBundle.putString(ADDRESS_LEVEL3_KEY, addressLevel3); + return this; + } + + /** + * Set the postal code for this address entry. + * + * @param postalCode The postal code string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder postalCode(final @Nullable String postalCode) { + mBundle.putString(POSTAL_CODE_KEY, postalCode); + return this; + } + + /** + * Set the country code for this address entry. + * + * @param country The country string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder country(final @Nullable String country) { + mBundle.putString(COUNTRY_KEY, country); + return this; + } + + /** + * Set the telephone number for this address entry. + * + * @param tel The telephone number string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder tel(final @Nullable String tel) { + mBundle.putString(TEL_KEY, tel); + return this; + } + + /** + * Set the email address for this address entry. + * + * @param email The email address string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder email(final @Nullable String email) { + mBundle.putString(EMAIL_KEY, email); + return this; + } + } + } + + /** Holds login information for a specific entry. */ + public static class LoginEntry { + private static final String GUID_KEY = "guid"; + private static final String ORIGIN_KEY = "origin"; + private static final String FORM_ACTION_ORIGIN_KEY = "formActionOrigin"; + private static final String HTTP_REALM_KEY = "httpRealm"; + private static final String USERNAME_KEY = "username"; + private static final String PASSWORD_KEY = "password"; + + /** The unique identifier for this login entry. */ + public final @Nullable String guid; + + /** The origin this login entry applies to. */ + public final @NonNull String origin; + + /** + * The origin this login entry was submitted to. This only applies to form-based login entries. + * It's derived from the action attribute set on the form element. + */ + public final @Nullable String formActionOrigin; + + /** + * The HTTP realm this login entry was requested for. This only applies to non-form-based login + * entries. It's derived from the WWW-Authenticate header set in a HTTP 401 response, see + * RFC2617 for details. + */ + public final @Nullable String httpRealm; + + /** The username for this login entry. */ + public final @NonNull String username; + + /** The password for this login entry. */ + public final @NonNull String password; + + // For tests only. + @AnyThread + protected LoginEntry() { + guid = null; + origin = ""; + formActionOrigin = null; + httpRealm = null; + username = ""; + password = ""; + } + + @AnyThread + /* package */ LoginEntry(final @NonNull GeckoBundle bundle) { + guid = bundle.getString(GUID_KEY); + origin = bundle.getString(ORIGIN_KEY, ""); + formActionOrigin = bundle.getString(FORM_ACTION_ORIGIN_KEY); + httpRealm = bundle.getString(HTTP_REALM_KEY); + username = bundle.getString(USERNAME_KEY, ""); + password = bundle.getString(PASSWORD_KEY, ""); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("LoginEntry {"); + builder + .append("guid=") + .append(guid) + .append(", origin=") + .append(origin) + .append(", formActionOrigin=") + .append(formActionOrigin) + .append(", httpRealm=") + .append(httpRealm) + .append(", username=") + .append(username) + .append(", password=") + .append(password) + .append("}"); + return builder.toString(); + } + + @AnyThread + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(6); + bundle.putString(GUID_KEY, guid); + bundle.putString(ORIGIN_KEY, origin); + bundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin); + bundle.putString(HTTP_REALM_KEY, httpRealm); + bundle.putString(USERNAME_KEY, username); + bundle.putString(PASSWORD_KEY, password); + + return bundle; + } + + public static class Builder { + private final GeckoBundle mBundle; + + @AnyThread + /* package */ Builder(final @NonNull GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mBundle = new GeckoBundle(6); + } + + /** + * Finalize the {@link LoginEntry} instance. + * + * @return The {@link LoginEntry} instance. + */ + @AnyThread + public @NonNull LoginEntry build() { + return new LoginEntry(mBundle); + } + + /** + * Set the unique identifier for this login entry. + * + * @param guid The unique identifier string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder guid(final @Nullable String guid) { + mBundle.putString(GUID_KEY, guid); + return this; + } + + /** + * Set the origin this login entry applies to. + * + * @param origin The origin string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder origin(final @NonNull String origin) { + mBundle.putString(ORIGIN_KEY, origin); + return this; + } + + /** + * Set the origin this login entry was submitted to. + * + * @param formActionOrigin The form action origin string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder formActionOrigin(final @Nullable String formActionOrigin) { + mBundle.putString(FORM_ACTION_ORIGIN_KEY, formActionOrigin); + return this; + } + + /** + * Set the HTTP realm this login entry was requested for. + * + * @param httpRealm The HTTP realm string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder httpRealm(final @Nullable String httpRealm) { + mBundle.putString(HTTP_REALM_KEY, httpRealm); + return this; + } + + /** + * Set the username for this login entry. + * + * @param username The username string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder username(final @NonNull String username) { + mBundle.putString(USERNAME_KEY, username); + return this; + } + + /** + * Set the password for this login entry. + * + * @param password The password string. + * @return This {@link Builder} instance. + */ + @AnyThread + public @NonNull Builder password(final @NonNull String password) { + mBundle.putString(PASSWORD_KEY, password); + return this; + } + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {UsedField.PASSWORD}) + public @interface LSUsedField {} + + // Sync with UsedField in GeckoViewAutocomplete.jsm. + /** Possible login entry field types for {@link StorageDelegate#onLoginUsed}. */ + public static class UsedField { + /** The password field of a login entry. */ + public static final int PASSWORD = 1; + + protected UsedField() {} + } + + /** + * Implement this interface to handle runtime login storage requests. Login storage events include + * login entry requests for autofill and autocompletion of login input fields. This delegate is + * attached to the runtime via {@link GeckoRuntime#setAutocompleteStorageDelegate}. + */ + public interface StorageDelegate { + /** + * Request login entries for a given domain. While processing the web document, we have + * identified elements resembling login input fields suitable for autofill. We will attempt to + * match the provided login information to the identified input fields. + * + * @param domain The domain string for the requested logins. + * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing + * the existing logins for the given domain. + */ + @UiThread + default @Nullable GeckoResult<LoginEntry[]> onLoginFetch(@NonNull final String domain) { + return null; + } + + /** + * Request login entries for all domains. + * + * @return A {@link GeckoResult} that completes with an array of {@link LoginEntry} containing + * the existing logins. + */ + @UiThread + default @Nullable GeckoResult<LoginEntry[]> onLoginFetch() { + return null; + } + + /** + * Request credit card entries. While processing the web document, we have identified elements + * resembling credit card input fields suitable for autofill. We will attempt to match the + * provided credit card information to the identified input fields. + * + * @return A {@link GeckoResult} that completes with an array of {@link CreditCard} containing + * the existing credit cards. + */ + @UiThread + default @Nullable GeckoResult<CreditCard[]> onCreditCardFetch() { + return null; + } + + /** + * Request address entries. While processing the web document, we have identified elements + * resembling address input fields suitable for autofill. We will attempt to match the provided + * address information to the identified input fields. + * + * @return A {@link GeckoResult} that completes with an array of {@link Address} containing the + * existing addresses. + */ + @UiThread + default @Nullable GeckoResult<Address[]> onAddressFetch() { + return null; + } + + /** + * Request saving or updating of the given login entry. This is triggered by confirming a {@link + * GeckoSession.PromptDelegate#onLoginSave onLoginSave} request. + * + * @param login The {@link LoginEntry} as confirmed by the prompt request. + */ + @UiThread + default void onLoginSave(@NonNull final LoginEntry login) {} + + /** + * Request saving or updating of the given credit card entry. This is triggered by confirming a + * {@link GeckoSession.PromptDelegate#onCreditCardSave onCreditCardSave} request. + * + * @param creditCard The {@link CreditCard} as confirmed by the prompt request. + */ + @UiThread + default void onCreditCardSave(@NonNull CreditCard creditCard) {} + + /** + * Request saving or updating of the given address entry. This is triggered by confirming a + * {@link GeckoSession.PromptDelegate#onAddressSave onAddressSave} request. + * + * @param address The {@link Address} as confirmed by the prompt request. + */ + @UiThread + default void onAddressSave(@NonNull Address address) {} + + /** + * Notify that the given login was used to autofill login input fields. This is triggered by + * autofilling elements with unmodified login entries as provided via {@link #onLoginFetch}. + * + * @param login The {@link LoginEntry} that was used for the autofilling. + * @param usedFields The login entry fields used for autofilling. A combination of {@link + * UsedField}. + */ + @UiThread + default void onLoginUsed(@NonNull final LoginEntry login, @LSUsedField final int usedFields) {} + } + + /** + * Abstract base class for Autocomplete options. Extended by {@link Autocomplete.SaveOption} and + * {@link Autocomplete.SelectOption}. + */ + public abstract static class Option<T> { + /* package */ static final String VALUE_KEY = "value"; + /* package */ static final String HINT_KEY = "hint"; + + public final @NonNull T value; + public final int hint; + + @SuppressWarnings("checkstyle:javadocmethod") + public Option(final @NonNull T value, final int hint) { + this.value = value; + this.hint = hint; + } + + @AnyThread + /* package */ abstract @NonNull GeckoBundle toBundle(); + } + + /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSaveOption}. */ + public abstract static class SaveOption<T> extends Option<T> { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.GENERATED, Hint.LOW_CONFIDENCE}) + public @interface SaveOptionHint {} + + /** Hint types for login saving requests. */ + public static class Hint { + public static final int NONE = 0; + + /** Auto-generated password. Notify but do not prompt the user for saving. */ + public static final int GENERATED = 1 << 0; + + /** + * Potentially non-login data. The form data entered may be not login credentials but other + * forms of input like credit card numbers. Note that this could be valid login data in same + * cases, e.g., some banks may expect credit card numbers in the username field. + */ + public static final int LOW_CONFIDENCE = 1 << 1; + + protected Hint() {} + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SaveOption(final @NonNull T value, final @SaveOptionHint int hint) { + super(value, hint); + } + } + + /** Abstract base class for saving options. Extended by {@link Autocomplete.LoginSelectOption}. */ + public abstract static class SelectOption<T> extends Option<T> { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Hint.NONE, + Hint.GENERATED, + Hint.INSECURE_FORM, + Hint.DUPLICATE_USERNAME, + Hint.MATCHING_ORIGIN + }) + public @interface SelectOptionHint {} + + /** Hint types for selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Auto-generated password. A new password-only login entry containing a secure generated + * password. + */ + public static final int GENERATED = 1 << 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + + /** + * The username is shared with another login entry. There are multiple login entries in the + * options that share the same username. You may have to disambiguate the login entry, e.g., + * using the last date of modification and its origin. + */ + public static final int DUPLICATE_USERNAME = 1 << 2; + + /** + * The login entry's origin matches the login form origin. The login was saved from the same + * origin it is being requested for, rather than for a subdomain. + */ + public static final int MATCHING_ORIGIN = 1 << 3; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SelectOption(final @NonNull T value, final @SelectOptionHint int hint) { + super(value, hint); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("SelectOption {"); + builder.append("value=").append(value).append(", ").append("hint=").append(hint).append("}"); + return builder.toString(); + } + } + + /** Holds information required to process login saving requests. */ + public static class LoginSaveOption extends SaveOption<LoginEntry> { + /** + * Construct a login save option. + * + * @param value The {@link LoginEntry} login entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ LoginSaveOption(final @NonNull LoginEntry value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a login save option. + * + * @param value The {@link LoginEntry} login entry to be saved. + */ + public LoginSaveOption(final @NonNull LoginEntry value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process address saving requests. */ + public static class AddressSaveOption extends SaveOption<Address> { + /** + * Construct a address save option. + * + * @param value The {@link Address} address entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ AddressSaveOption(final @NonNull Address value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct an address save option. + * + * @param value The {@link Address} address entry to be saved. + */ + public AddressSaveOption(final @NonNull Address value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process credit card saving requests. */ + public static class CreditCardSaveOption extends SaveOption<CreditCard> { + /** + * Construct a credit card save option. + * + * @param value The {@link CreditCard} credit card entry to be saved. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ CreditCardSaveOption( + final @NonNull CreditCard value, final @SaveOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a credit card save option. + * + * @param value The {@link CreditCard} credit card entry to be saved. + */ + public CreditCardSaveOption(final @NonNull CreditCard value) { + this(value, Hint.NONE); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process login selection requests. */ + public static class LoginSelectOption extends SelectOption<LoginEntry> { + /** + * Construct a login select option. + * + * @param value The {@link LoginEntry} login entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ LoginSelectOption( + final @NonNull LoginEntry value, final @SelectOptionHint int hint) { + super(value, hint); + } + + /** + * Construct a login select option. + * + * @param value The {@link LoginEntry} login entry selection option. + */ + public LoginSelectOption(final @NonNull LoginEntry value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull LoginSelectOption fromBundle(final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final LoginEntry value = new LoginEntry(bundle.getBundle("value")); + + return new LoginSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process credit card selection requests. */ + public static class CreditCardSelectOption extends SelectOption<CreditCard> { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.INSECURE_FORM}) + public @interface CreditCardSelectHint {} + + /** Hint types for credit card selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + } + + /** + * Construct a credit card select option. + * + * @param value The {@link LoginEntry} credit card entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ CreditCardSelectOption( + final @NonNull CreditCard value, final @CreditCardSelectHint int hint) { + super(value, hint); + } + + /** + * Construct a credit card select option. + * + * @param value The {@link CreditCard} credit card entry selection option. + */ + public CreditCardSelectOption(final @NonNull CreditCard value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull CreditCardSelectOption fromBundle( + final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final CreditCard value = new CreditCard(bundle.getBundle("value")); + + return new CreditCardSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /** Holds information required to process address selection requests. */ + public static class AddressSelectOption extends SelectOption<Address> { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {Hint.NONE, Hint.INSECURE_FORM}) + public @interface AddressSelectHint {} + + /** Hint types for credit card selection requests. */ + public static class Hint { + public static final int NONE = 0; + + /** + * Insecure context. The form or transmission mechanics are considered insecure. This is the + * case when the form is served via http or submitted insecurely. + */ + public static final int INSECURE_FORM = 1 << 1; + } + + /** + * Construct a credit card select option. + * + * @param value The {@link LoginEntry} credit card entry selection option. + * @param hint The {@link Hint} detailing the type of the option. + */ + /* package */ AddressSelectOption( + final @NonNull Address value, final @AddressSelectHint int hint) { + super(value, hint); + } + + /** + * Construct a address select option. + * + * @param value The {@link Address} address entry selection option. + */ + public AddressSelectOption(final @NonNull Address value) { + this(value, Hint.NONE); + } + + /* package */ static @NonNull AddressSelectOption fromBundle( + final @NonNull GeckoBundle bundle) { + final int hint = bundle.getInt("hint"); + final Address value = new Address(bundle.getBundle("value")); + + return new AddressSelectOption(value, hint); + } + + @Override + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putBundle(VALUE_KEY, value.toBundle()); + bundle.putInt(HINT_KEY, hint); + return bundle; + } + } + + /* package */ static final class StorageProxy implements BundleEventListener { + private static final String FETCH_LOGIN_EVENT = "GeckoView:Autocomplete:Fetch:Login"; + private static final String FETCH_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Fetch:CreditCard"; + private static final String FETCH_ADDRESS_EVENT = "GeckoView:Autocomplete:Fetch:Address"; + private static final String SAVE_LOGIN_EVENT = "GeckoView:Autocomplete:Save:Login"; + private static final String SAVE_CREDIT_CARD_EVENT = "GeckoView:Autocomplete:Save:CreditCard"; + private static final String SAVE_ADDRESS_EVENT = "GeckoView:Autocomplete:Save:Address"; + private static final String USED_LOGIN_EVENT = "GeckoView:Autocomplete:Used:Login"; + + private @Nullable StorageDelegate mDelegate; + + public StorageProxy() {} + + private void registerListener() { + EventDispatcher.getInstance().dispatch("GeckoView:StorageDelegate:Attached", null); + EventDispatcher.getInstance() + .registerUiThreadListener( + this, + FETCH_LOGIN_EVENT, + FETCH_CREDIT_CARD_EVENT, + FETCH_ADDRESS_EVENT, + SAVE_LOGIN_EVENT, + SAVE_CREDIT_CARD_EVENT, + SAVE_ADDRESS_EVENT, + USED_LOGIN_EVENT); + } + + private void unregisterListener() { + EventDispatcher.getInstance() + .unregisterUiThreadListener( + this, + FETCH_LOGIN_EVENT, + FETCH_CREDIT_CARD_EVENT, + FETCH_ADDRESS_EVENT, + SAVE_LOGIN_EVENT, + SAVE_CREDIT_CARD_EVENT, + SAVE_ADDRESS_EVENT, + USED_LOGIN_EVENT); + } + + public synchronized void setDelegate(final @Nullable StorageDelegate delegate) { + if (mDelegate == delegate) { + return; + } + if (mDelegate != null) { + unregisterListener(); + } + + mDelegate = delegate; + + if (mDelegate != null) { + registerListener(); + } + } + + public synchronized @Nullable StorageDelegate getDelegate() { + return mDelegate; + } + + @Override // BundleEventListener + public synchronized void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, "handleMessage " + event); + } + + if (mDelegate == null) { + if (callback != null) { + callback.sendError("No StorageDelegate attached"); + } + return; + } + + if (FETCH_LOGIN_EVENT.equals(event)) { + final String domain = message.getString("domain"); + final GeckoResult<Autocomplete.LoginEntry[]> result = + domain != null ? mDelegate.onLoginFetch(domain) : mDelegate.onLoginFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + logins -> { + if (logins == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] loginBundles = new GeckoBundle[logins.length]; + for (int i = 0; i < logins.length; ++i) { + loginBundles[i] = logins[i].toBundle(); + } + + return loginBundles; + })); + } else if (FETCH_CREDIT_CARD_EVENT.equals(event)) { + final GeckoResult<Autocomplete.CreditCard[]> result = mDelegate.onCreditCardFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + creditCards -> { + if (creditCards == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] creditCardBundles = new GeckoBundle[creditCards.length]; + for (int i = 0; i < creditCards.length; ++i) { + creditCardBundles[i] = creditCards[i].toBundle(); + } + + return creditCardBundles; + })); + } else if (FETCH_ADDRESS_EVENT.equals(event)) { + final GeckoResult<Autocomplete.Address[]> result = mDelegate.onAddressFetch(); + + if (result == null) { + callback.sendSuccess(new GeckoBundle[0]); + return; + } + + callback.resolveTo( + result.map( + addresses -> { + if (addresses == null) { + return new GeckoBundle[0]; + } + + // This is a one-liner with streams (API level 24). + final GeckoBundle[] addressBundles = new GeckoBundle[addresses.length]; + for (int i = 0; i < addresses.length; ++i) { + addressBundles[i] = addresses[i].toBundle(); + } + + return addressBundles; + })); + } else if (SAVE_LOGIN_EVENT.equals(event)) { + final GeckoBundle loginBundle = message.getBundle("login"); + final LoginEntry login = new LoginEntry(loginBundle); + + mDelegate.onLoginSave(login); + } else if (SAVE_CREDIT_CARD_EVENT.equals(event)) { + final GeckoBundle creditCardBundle = message.getBundle("creditCard"); + final CreditCard creditCard = new CreditCard(creditCardBundle); + + mDelegate.onCreditCardSave(creditCard); + } else if (SAVE_ADDRESS_EVENT.equals(event)) { + final GeckoBundle addressBundle = message.getBundle("address"); + final Address address = new Address(addressBundle); + + mDelegate.onAddressSave(address); + } else if (USED_LOGIN_EVENT.equals(event)) { + final GeckoBundle loginBundle = message.getBundle("login"); + final LoginEntry login = new LoginEntry(loginBundle); + final int fields = message.getInt("usedFields"); + + mDelegate.onLoginUsed(login, fields); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java new file mode 100644 index 0000000000..c1625693cf --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Autofill.java @@ -0,0 +1,1229 @@ +/* -*- 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.geckoview; + +import android.annotation.TargetApi; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.util.Log; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewStructure; +import android.view.autofill.AutofillValue; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.collection.ArrayMap; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collection; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public class Autofill { + private static final boolean DEBUG = false; + + public @interface AutofillNotify {} + + public static final class Hint { + private Hint() {} + + /** Hint indicating that no special handling is required. */ + public static final int NONE = -1; + + /** Hint indicating that a node represents an email address. */ + public static final int EMAIL_ADDRESS = 0; + + /** Hint indicating that a node represents a password. */ + public static final int PASSWORD = 1; + + /** Hint indicating that a node represents an URI. */ + public static final int URI = 2; + + /** Hint indicating that a node represents a username. */ + public static final int USERNAME = 3; + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public static @Nullable String toString(final @AutofillHint int hint) { + final int idx = hint + 1; + final String[] map = new String[] {"NONE", "EMAIL", "PASSWORD", "URI", "USERNAME"}; + + if (idx < 0 || idx >= map.length) { + return null; + } + return map[idx]; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Hint.NONE, Hint.EMAIL_ADDRESS, Hint.PASSWORD, Hint.URI, Hint.USERNAME}) + public @interface AutofillHint {} + + public static final class InputType { + private InputType() {} + + /** Indicates that a node is not a known input type. */ + public static final int NONE = -1; + + /** Indicates that a node is a text input type. Example: {@code <input type="text">} */ + public static final int TEXT = 0; + + /** Indicates that a node is a number input type. Example: {@code <input type="number">} */ + public static final int NUMBER = 1; + + /** Indicates that a node is a phone input type. Example: {@code <input type="tel">} */ + public static final int PHONE = 2; + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public static @Nullable String toString(final @AutofillInputType int type) { + final int idx = type + 1; + final String[] map = new String[] {"NONE", "TEXT", "NUMBER", "PHONE"}; + + if (idx < 0 || idx >= map.length) { + return null; + } + return map[idx]; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({InputType.NONE, InputType.TEXT, InputType.NUMBER, InputType.PHONE}) + public @interface AutofillInputType {} + + /** Represents autofill data associated to a {@link Node}. */ + public static class NodeData { + /** Autofill id for this node. */ + final int id; + + String value; + Node node; + EventCallback callback; + + NodeData(final int id, final Node node) { + this.id = id; + this.node = node; + } + + /** + * Gets the value for this node. + * + * @return a String representing the value for this node. + */ + @AnyThread + public @Nullable String getValue() { + return value; + } + + /** + * Returns the autofill id for this node. + * + * @return an int representing the id for this node. + */ + @AnyThread + public int getId() { + return id; + } + } + + /** Represents an autofill session. A session holds the autofill nodes and state of a page. */ + public static final class Session { + private static final String LOGTAG = "AutofillSession"; + + private @NonNull final GeckoSession mGeckoSession; + private Node mRoot; + private HashMap<String, NodeData> mUuidToNodeData; + private SparseArray<Node> mIdToNode; + private int mCurrentIndex = 0; + private String mId = null; + + // We can't store the Node directly because it might be updated by subsequent NodeAdd calls. + private String mFocusedUuid = null; + + /* package */ Session(@NonNull final GeckoSession geckoSession) { + mGeckoSession = geckoSession; + // Dummy session until a real one gets created + clear(UUID.randomUUID().toString()); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull Rect getDefaultDimensions() { + final Rect rect = new Rect(); + mGeckoSession.getSurfaceBounds(rect); + return rect; + } + + /* package */ void clear(final String newSessionId) { + mId = newSessionId; + mFocusedUuid = null; + mRoot = Node.newDummyRoot(getDefaultDimensions(), newSessionId); + mIdToNode = new SparseArray<>(); + mUuidToNodeData = new HashMap<>(); + addNode(mRoot); + } + + /* package */ boolean isEmpty() { + // Root data is always there + return mUuidToNodeData.size() == 1; + } + + /** + * Get data for the given node. + * + * @param node the {@link Node} get data for. + * @return the {@link NodeData} for the given node. + */ + @UiThread + public @NonNull NodeData dataFor(final @NonNull Node node) { + final NodeData data = mUuidToNodeData.get(node.getUuid()); + Objects.requireNonNull(data); + return data; + } + + /** + * Perform auto-fill using the specified values. + * + * @param values Map of auto-fill IDs to values. + */ + @UiThread + public void autofill(@NonNull final SparseArray<CharSequence> values) { + ThreadUtils.assertOnUiThread(); + + if (isEmpty()) { + return; + } + + final HashMap<Node, GeckoBundle> valueBundles = new HashMap<>(); + + for (int i = 0; i < values.size(); i++) { + final int id = values.keyAt(i); + final Node node = getNode(id); + if (node == null) { + Log.w(LOGTAG, "Could not find node id=" + id); + continue; + } + + final CharSequence value = values.valueAt(i); + + if (DEBUG) { + Log.d(LOGTAG, "Process autofill for id=" + id + ", value=" + value); + } + + if (node == getRoot()) { + // We cannot autofill the session root as it does not correspond to a + // real element on the page. + Log.w(LOGTAG, "Ignoring autofill on session root."); + continue; + } + + final Node root = node.getRoot(); + if (!valueBundles.containsKey(root)) { + valueBundles.put(root, new GeckoBundle()); + } + valueBundles.get(root).putString(node.getUuid(), String.valueOf(value)); + } + + for (final Node root : valueBundles.keySet()) { + final NodeData data = dataFor(root); + Objects.requireNonNull(data); + final EventCallback callback = data.callback; + callback.sendSuccess(valueBundles.get(root)); + } + } + + /* package */ void addRoot(@NonNull final Node node, final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, "addRoot: " + node); + } + + mRoot.addChild(node); + addNode(node); + dataFor(node).callback = callback; + } + + /* package */ void addNode(@NonNull final Node node) { + if (DEBUG) { + Log.d(LOGTAG, "addNode: " + node); + } + + NodeData data = mUuidToNodeData.get(node.getUuid()); + if (data == null) { + final int nodeId = mCurrentIndex++; + data = new NodeData(nodeId, node); + mUuidToNodeData.put(node.getUuid(), data); + } else { + data.node = node; + } + + mIdToNode.put(data.id, node); + for (final Node child : node.getChildren()) { + addNode(child); + } + } + + /** + * Returns true if the node is currently visible in the page. + * + * @param node the {@link Node} instance + * @return true if the node is visible, false otherwise. + */ + @UiThread + public boolean isVisible(final @NonNull Node node) { + if (!Objects.equals(node.mSessionId, mId)) { + Log.w(LOGTAG, "Requesting visibility for older session " + node.mSessionId); + return false; + } + if (mRoot == node) { + // The root is always visible + return true; + } + final Node focused = getFocused(); + if (focused == null) { + return false; + } + final Node focusedRoot = focused.getRoot(); + final Node focusedParent = focused.getParent(); + + final String parentUuid = node.getParent() != null ? node.getParent().getUuid() : null; + final String rootUuid = node.getRoot() != null ? node.getRoot().getUuid() : null; + + return (focusedParent != null && focusedParent.getUuid().equals(parentUuid)) + || (focusedRoot != null && focusedRoot.getUuid().equals(rootUuid)); + } + + /** + * Returns the currently focused node. + * + * @return a reference to the {@link Node} that is currently focused or null if no node is + * currently focused. + */ + @UiThread + public @Nullable Node getFocused() { + return getNode(mFocusedUuid); + } + + /* package */ void setFocus(final Node node) { + mFocusedUuid = node != null ? node.getUuid() : null; + } + + /** + * Returns the currently focused node data. + * + * @return a refernce to {@link NodeData} or null if no node is focused. + */ + @UiThread + public @Nullable NodeData getFocusedData() { + final Node focused = getFocused(); + return focused != null ? dataFor(focused) : null; + } + + /* package */ @Nullable + Node getNode(final String uuid) { + if (uuid == null) { + return null; + } + final NodeData nodeData = mUuidToNodeData.get(uuid); + if (nodeData == null) { + return null; + } + return nodeData.node; + } + + /* package */ Node getNode(final int id) { + return mIdToNode.get(id); + } + + /** + * Get the root node of the session tree. Each session is managed in a tree with a virtual root + * node for the document. + * + * @return The root {@link Node} for this session. + */ + @AnyThread + public @NonNull Node getRoot() { + return mRoot; + } + + /* package */ String getId() { + return mId; + } + + @Override + @UiThread + public String toString() { + final StringBuilder builder = new StringBuilder("Session {"); + final Node focused = getFocused(); + builder + .append("id=") + .append(mId) + .append(", focused=") + .append(mFocusedUuid) + .append(", focusedRoot=") + .append( + (focused != null && focused.getRoot() != null) ? focused.getRoot().getUuid() : null) + .append(", root=") + .append(getRoot()) + .append("}"); + return builder.toString(); + } + + @TargetApi(23) + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public void fillViewStructure( + @NonNull final View view, @NonNull final ViewStructure structure, final int flags) { + ThreadUtils.assertOnUiThread(); + fillViewStructure(getRoot(), view, structure, flags); + } + + @TargetApi(23) + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public void fillViewStructure( + final @NonNull Node node, + @NonNull final View view, + @NonNull final ViewStructure structure, + final int flags) { + ThreadUtils.assertOnUiThread(); + + if (DEBUG) { + Log.d(LOGTAG, "fillViewStructure"); + } + + final NodeData data = dataFor(node); + if (data == null) { + return; + } + + if (Build.VERSION.SDK_INT >= 26) { + structure.setAutofillId(view.getAutofillId(), data.id); + structure.setWebDomain(node.getDomain()); + structure.setAutofillValue(AutofillValue.forText(data.value)); + } + + structure.setId(data.id, null, null, null); + // This dimensions doesn't seem to used for autofill service. + structure.setDimens(0, 0, 0, 0, node.getDimensions().width(), node.getDimensions().height()); + + if (Build.VERSION.SDK_INT >= 26) { + final ViewStructure.HtmlInfo.Builder htmlBuilder = + structure.newHtmlInfoBuilder(node.getTag()); + for (final String key : node.getAttributes().keySet()) { + htmlBuilder.addAttribute(key, String.valueOf(node.getAttribute(key))); + } + + structure.setHtmlInfo(htmlBuilder.build()); + } + + structure.setChildCount(node.getChildren().size()); + int childCount = 0; + + for (final Node child : node.getChildren()) { + final ViewStructure childStructure = structure.newChild(childCount); + fillViewStructure(child, view, childStructure, flags); + childCount++; + } + + switch (node.getTag()) { + case "input": + case "textarea": + structure.setClassName("android.widget.EditText"); + structure.setEnabled(node.getEnabled()); + structure.setFocusable(node.getFocusable()); + structure.setFocused(node.equals(getFocused())); + structure.setVisibility(isVisible(node) ? View.VISIBLE : View.INVISIBLE); + + if (Build.VERSION.SDK_INT >= 26) { + structure.setAutofillType(View.AUTOFILL_TYPE_TEXT); + } + break; + default: + if (childCount > 0) { + structure.setClassName("android.view.ViewGroup"); + } else { + structure.setClassName("android.view.View"); + } + break; + } + + if (Build.VERSION.SDK_INT < 26 || !"input".equals(node.getTag())) { + return; + } + // LastPass will fill password to the field where setAutofillHints + // is unset and setInputType is set. + switch (node.getHint()) { + case Hint.EMAIL_ADDRESS: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_EMAIL_ADDRESS}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS); + break; + } + case Hint.PASSWORD: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PASSWORD}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD); + break; + } + case Hint.URI: + { + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_URI); + break; + } + case Hint.USERNAME: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_USERNAME}); + structure.setInputType( + android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT); + break; + } + case Hint.NONE: + { + // Nothing to do. + break; + } + } + + switch (node.getInputType()) { + case InputType.NUMBER: + { + structure.setInputType(android.text.InputType.TYPE_CLASS_NUMBER); + break; + } + case InputType.PHONE: + { + structure.setAutofillHints(new String[] {View.AUTOFILL_HINT_PHONE}); + structure.setInputType(android.text.InputType.TYPE_CLASS_PHONE); + break; + } + case InputType.TEXT: + case InputType.NONE: + // Nothing to do. + break; + } + } + } + + /** + * Represents an autofill node. A node is an input element and may contain child nodes forming a + * tree. + */ + public static final class Node { + private final String mUuid; + private final Node mRoot; + private final Node mParent; + private final @NonNull Rect mDimens; + private final @NonNull Rect mScreenRect; + private final @NonNull Map<String, Node> mChildren; + private final @NonNull Map<String, String> mAttributes; + private final boolean mEnabled; + private final boolean mFocusable; + private final @AutofillHint int mHint; + private final @AutofillInputType int mInputType; + private final @NonNull String mTag; + private final @NonNull String mDomain; + private final String mSessionId; + + /* package */ + @NonNull + String getUuid() { + return mUuid; + } + + /* package */ + @Nullable + Node getRoot() { + return mRoot; + } + + /* package */ + @Nullable + Node getParent() { + return mParent; + } + + /** + * Get the dimensions of this node in CSS coordinates. Note: Invisible nodes will report their + * proper dimensions. + * + * @return The dimensions of this node. + * @deprecated Use {@link #getScreenRect}. + */ + @Deprecated + @DeprecationSchedule(id = "autofill-fission", version = 112) // should be package private + @AnyThread + public @NonNull Rect getDimensions() { + return mDimens; + } + + /** + * Get the dimensions of this node in screen coordinates. This is valid when this node has an + * focus. + * + * @return The dimensions of this node. + */ + @AnyThread + public @NonNull Rect getScreenRect() { + return mScreenRect; + } + + /** + * Set the dimensions of this node in screen coordinates. + * + * @param screenRect The dimensions of this node. + */ + /* package */ void setScreenRect(final @NonNull RectF screenRectF) { + screenRectF.roundOut(mScreenRect); + } + + /** + * Get the child nodes for this node. + * + * @return The collection of child nodes for this node. + */ + @AnyThread + public @NonNull Collection<Node> getChildren() { + return mChildren.values(); + } + + /* package */ + @NonNull + Node addChild(@NonNull final Node child) { + mChildren.put(child.getUuid(), child); + return this; + } + + /** + * Get HTML attributes for this node. + * + * @return The HTML attributes for this node. + */ + @AnyThread + public @NonNull Map<String, String> getAttributes() { + return mAttributes; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable String getAttribute(@NonNull final String key) { + return mAttributes.get(key); + } + + /** + * Get whether or not this node is enabled. + * + * @return True if the node is enabled, false otherwise. + */ + @AnyThread + public boolean getEnabled() { + return mEnabled; + } + + /** + * Get whether or not this node is focusable. + * + * @return True if the node is focusable, false otherwise. + */ + @AnyThread + public boolean getFocusable() { + return mFocusable; + } + + /** + * Get the hint for the type of data contained in this node. + * + * @return The input data hint for this node, one of {@link Hint}. + */ + @AnyThread + public @AutofillHint int getHint() { + return mHint; + } + + /** + * Get the input type of this node. + * + * @return The input type of this node, one of {@link InputType}. + */ + @AnyThread + public @AutofillInputType int getInputType() { + return mInputType; + } + + /** + * Get the HTML tag of this node. + * + * @return The HTML tag of this node. + */ + @AnyThread + public @NonNull String getTag() { + return mTag; + } + + /** + * Get web domain of this node. + * + * @return The domain of this node. + */ + @AnyThread + public @NonNull String getDomain() { + return mDomain; + } + + /* package */ + static Node newDummyRoot(final Rect dimensions, final String sessionId) { + return new Node(dimensions, sessionId); + } + + /* package */ Node(final Rect dimensions, final String sessionId) { + mRoot = null; + mParent = null; + mUuid = UUID.randomUUID().toString(); + mDimens = dimensions; + mScreenRect = new Rect(); + mSessionId = sessionId; + mAttributes = new ArrayMap<>(); + mEnabled = false; + mFocusable = false; + mHint = Hint.NONE; + mInputType = InputType.NONE; + mTag = ""; + mDomain = ""; + mChildren = new HashMap<>(); + } + + @Override + @AnyThread + public String toString() { + final StringBuilder builder = new StringBuilder("Node {"); + builder + .append("uuid=") + .append(mUuid) + .append(", sessionId=") + .append(mSessionId) + .append(", parent=") + .append(mParent != null ? mParent.getUuid() : null) + .append(", root=") + .append(mRoot != null ? mRoot.getUuid() : null) + .append(", dims=") + .append(getDimensions().toShortString()) + .append(", screenRect=") + .append(getScreenRect().toShortString()) + .append(", children=["); + + for (final Node child : mChildren.values()) { + builder.append(child.getUuid()).append(", "); + } + + builder + .append("]") + .append(", attrs=") + .append(mAttributes) + .append(", enabled=") + .append(mEnabled) + .append(", focusable=") + .append(mFocusable) + .append(", hint=") + .append(Hint.toString(mHint)) + .append(", type=") + .append(InputType.toString(mInputType)) + .append(", tag=") + .append(mTag) + .append(", domain=") + .append(mDomain) + .append("}"); + + return builder.toString(); + } + + /* package */ Node( + @NonNull final GeckoBundle bundle, final Rect defaultDimensions, final String sessionId) { + this(bundle, /* root */ null, /* parent */ null, defaultDimensions, sessionId); + } + + /* package */ Node( + @NonNull final GeckoBundle bundle, + final Node root, + final Node parent, + final Rect defaultDimensions, + final String sessionId) { + final GeckoBundle bounds = bundle.getBundle("bounds"); + + mSessionId = sessionId; + mUuid = bundle.getString("uuid"); + mDomain = bundle.getString("origin", ""); + final Rect dimens = + new Rect( + bounds.getInt("left"), + bounds.getInt("top"), + bounds.getInt("right"), + bounds.getInt("bottom")); + if (dimens.isEmpty()) { + // Some nodes like <html> will have null-dimensions, + // we need to set them to the virtual documents dimensions. + mDimens = defaultDimensions; + } else { + mDimens = dimens; + } + mScreenRect = new Rect(); + + mParent = parent; + // If the root is null, then this object is the root itself + mRoot = root != null ? root : this; + + final GeckoBundle[] children = bundle.getBundleArray("children"); + final Map<String, Node> childrenMap = new HashMap<>(children != null ? children.length : 0); + + if (children != null) { + for (final GeckoBundle childBundle : children) { + final Node child = new Node(childBundle, mRoot, this, defaultDimensions, sessionId); + childrenMap.put(child.getUuid(), child); + } + } + + mChildren = childrenMap; + + mTag = bundle.getString("tag", "").toLowerCase(Locale.ROOT); + + final GeckoBundle attrs = bundle.getBundle("attributes"); + final Map<String, String> attributes = new HashMap<>(); + + for (final String key : attrs.keys()) { + attributes.put(key, String.valueOf(attrs.get(key))); + } + + mAttributes = attributes; + + mEnabled = + enabledFromBundle( + mTag, bundle.getBoolean("editable", false), bundle.getBoolean("disabled", false)); + mFocusable = mEnabled; + + final String type = bundle.getString("type", "text").toLowerCase(Locale.ROOT); + final String hint = bundle.getString("autofillhint", "").toLowerCase(Locale.ROOT); + mInputType = typeFromBundle(type, hint); + mHint = hintFromBundle(type, hint); + } + + private boolean enabledFromBundle( + final String tag, final boolean editable, final boolean disabled) { + switch (tag) { + case "input": + { + if (!editable) { + // Don't process non-editable inputs (e.g., type="button"). + return false; + } + return !disabled; + } + case "textarea": + return !disabled; + default: + return false; + } + } + + private @AutofillHint int hintFromBundle(final String type, final String hint) { + switch (type) { + case "email": + return Hint.EMAIL_ADDRESS; + case "password": + return Hint.PASSWORD; + case "url": + return Hint.URI; + case "text": + { + if (hint.equals("username")) { + return Hint.USERNAME; + } + break; + } + } + + return Hint.NONE; + } + + private @AutofillInputType int typeFromBundle(final String type, final String hint) { + switch (type) { + case "password": + case "url": + case "email": + return InputType.TEXT; + case "number": + return InputType.NUMBER; + case "tel": + return InputType.PHONE; + case "text": + { + if (hint.equals("username")) { + return InputType.TEXT; + } + break; + } + } + + return InputType.NONE; + } + } + + public interface Delegate { + + /** + * An autofill session has started. Usually triggered by page load. + * + * @param session The {@link GeckoSession} instance. + */ + @UiThread + default void onSessionStart(@NonNull final GeckoSession session) {} + /** + * An autofill session has been committed. Triggered by form submission or navigation. + * + * @param session The {@link GeckoSession} instance. + * @param node the node that is being committed. + * @param data the node data associated to the node being committed. + */ + @UiThread + default void onSessionCommit( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + /** + * An autofill session has been canceled. Triggered by page unload. + * + * @param session The {@link GeckoSession} instance. + */ + @UiThread + default void onSessionCancel(@NonNull final GeckoSession session) {} + /** + * A node within the autofill session has been added. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was added. + * @param data The {@link NodeData} associated to the note that was added. + */ + @UiThread + default void onNodeAdd( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + /** + * A node within the autofill session has been removed. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was removed. + * @param data The {@link NodeData} associated to the note that was removed. + */ + @UiThread + default void onNodeRemove( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + /** + * A node within the autofill session has been updated. + * + * @param session The {@link GeckoSession} instance. + * @param node The {@link Node} that was updated. + * @param data The {@link NodeData} associated to the note that was updated. + */ + @UiThread + default void onNodeUpdate( + @NonNull final GeckoSession session, + @NonNull final Node node, + @NonNull final NodeData data) {} + /** + * A node within the autofill session has gained focus. + * + * @param session The {@link GeckoSession} instance. + * @param focused The {@link Node} that is now focused. + * @param data The {@link NodeData} associated to the note that is now focused. + */ + @UiThread + default void onNodeFocus( + @NonNull final GeckoSession session, + @NonNull final Node focused, + @NonNull final NodeData data) {} + /** + * A node within the autofill session has lost focus. + * + * @param session The {@link GeckoSession} instance. + * @param prev The {@link Node} that lost focus. + * @param data The {@link NodeData} associated to the note that lost focus. + */ + @UiThread + default void onNodeBlur( + @NonNull final GeckoSession session, + @NonNull final Node prev, + @NonNull final NodeData data) {} + } + + /* package */ static final class Support implements BundleEventListener { + private static final String LOGTAG = "AutofillSupport"; + + private @NonNull final GeckoSession mGeckoSession; + private @NonNull final Session mAutofillSession; + private Delegate mDelegate; + + public Support(@NonNull final GeckoSession geckoSession) { + mGeckoSession = geckoSession; + mAutofillSession = new Session(mGeckoSession); + } + + public void registerListeners() { + mGeckoSession + .getEventDispatcher() + .registerUiThreadListener( + this, + "GeckoView:StartAutofill", + "GeckoView:AddAutofill", + "GeckoView:ClearAutofill", + "GeckoView:CommitAutofill", + "GeckoView:OnAutofillFocus", + "GeckoView:UpdateAutofill"); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event); + if ("GeckoView:AddAutofill".equals(event)) { + addNode(message.getBundle("node"), callback); + } else if ("GeckoView:StartAutofill".equals(event)) { + start(message.getString("sessionId")); + } else if ("GeckoView:ClearAutofill".equals(event)) { + clear(); + } else if ("GeckoView:OnAutofillFocus".equals(event)) { + onFocusChanged(message.getBundle("node")); + } else if ("GeckoView:CommitAutofill".equals(event)) { + commit(message.getBundle("node")); + } else if ("GeckoView:UpdateAutofill".equals(event)) { + update(message.getBundle("node")); + } + } + + @UiThread + public void setDelegate(final @Nullable Delegate delegate) { + ThreadUtils.assertOnUiThread(); + + mDelegate = delegate; + } + + @UiThread + public @Nullable Delegate getDelegate() { + ThreadUtils.assertOnUiThread(); + + return mDelegate; + } + + @UiThread + public @NonNull Session getAutofillSession() { + ThreadUtils.assertOnUiThread(); + + return mAutofillSession; + } + + /* package */ void addNode( + @NonNull final GeckoBundle message, @NonNull final EventCallback callback) { + final Session session = getAutofillSession(); + final Node node = new Node(message, session.getDefaultDimensions(), session.getId()); + + session.addRoot(node, callback); + addValues(message); + + if (mDelegate != null) { + mDelegate.onNodeAdd(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + private void addValues(final GeckoBundle message) { + final String uuid = message.getString("uuid"); + if (uuid == null) { + return; + } + + final String value = message.getString("value"); + final Node node = getAutofillSession().getNode(uuid); + if (node == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + Objects.requireNonNull(node); + final NodeData data = getAutofillSession().dataFor(node); + Objects.requireNonNull(data); + data.value = value; + + final GeckoBundle[] children = message.getBundleArray("children"); + if (children != null) { + for (final GeckoBundle child : children) { + addValues(child); + } + } + } + + /* package */ void start(@Nullable final String sessionId) { + // Make sure we start with a clean session + getAutofillSession().clear(sessionId); + if (mDelegate != null) { + mDelegate.onSessionStart(mGeckoSession); + } + } + + /* package */ void commit(@Nullable final GeckoBundle message) { + if (getAutofillSession().isEmpty() || message == null) { + return; + } + + final String uuid = message.getString("uuid"); + final Node node = getAutofillSession().getNode(uuid); + if (node == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "commit(" + uuid + ")"); + } + + if (mDelegate != null) { + mDelegate.onSessionCommit(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + /* package */ void update(@Nullable final GeckoBundle message) { + if (getAutofillSession().isEmpty() || message == null) { + return; + } + + final String uuid = message.getString("uuid"); + + if (DEBUG) { + Log.d(LOGTAG, "update(" + uuid + ")"); + } + + final Node node = getAutofillSession().getNode(uuid); + final String value = message.getString("value", ""); + + if (node == null) { + Log.d(LOGTAG, "could not find node " + uuid); + return; + } + + if (DEBUG) { + final NodeData data = getAutofillSession().dataFor(node); + Log.d( + LOGTAG, + "updating node " + uuid + " value from " + data != null + ? data.value + : null + " to " + value); + } + + getAutofillSession().dataFor(node).value = value; + + if (mDelegate != null) { + mDelegate.onNodeUpdate(mGeckoSession, node, getAutofillSession().dataFor(node)); + } + } + + /* package */ void clear() { + if (getAutofillSession().isEmpty()) { + return; + } + + if (DEBUG) { + Log.d(LOGTAG, "clear()"); + } + + getAutofillSession().clear(null); + if (mDelegate != null) { + mDelegate.onSessionCancel(mGeckoSession); + } + } + + /* package */ void onFocusChanged(@Nullable final GeckoBundle message) { + final Session session = getAutofillSession(); + if (session.isEmpty()) { + return; + } + + final Node prev = getAutofillSession().getFocused(); + final String prevUuid = prev != null ? prev.getUuid() : null; + final String uuid = message != null ? message.getString("uuid") : null; + + final Node focused; + if (uuid == null) { + focused = null; + } else { + focused = session.getNode(uuid); + if (focused == null) { + Log.w(LOGTAG, "Cannot find node uuid=" + uuid); + return; + } + if (message != null) { + final RectF screenRectF = message.getRectF("screenRect"); + focused.setScreenRect(screenRectF); + } + } + + if (DEBUG) { + Log.d( + LOGTAG, + "onFocusChanged(" + (prev != null ? prev.getUuid() : null) + " -> " + uuid + ')'); + } + + if (Objects.equals(uuid, prevUuid)) { + // Nothing changed, nothing to do. + return; + } + + session.setFocus(focused); + + if (mDelegate != null) { + if (prev != null) { + mDelegate.onNodeBlur(mGeckoSession, prev, getAutofillSession().dataFor(prev)); + } + if (uuid != null) { + mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } + } + } + + @UiThread + public void onActiveChanged(final boolean active) { + ThreadUtils.assertOnUiThread(); + + final Node focused = getAutofillSession().getFocused(); + + if (focused == null) { + return; + } + + if (mDelegate != null) { + if (active) { + mDelegate.onNodeFocus(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } else { + mDelegate.onNodeBlur(mGeckoSession, focused, getAutofillSession().dataFor(focused)); + } + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java new file mode 100644 index 0000000000..d135194afa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Base64Utils.java @@ -0,0 +1,20 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * This class exposes the Base64 URL encode/decode functions from Gecko. They are different from + * android.util.Base64 in that they always use URL encoding, no padding, and are constant time. The + * last bit is important when dealing with values that might be secret as we do with Web Push. + */ +/* package */ class Base64Utils { + @WrapForJNI + public static native byte[] decode(final String data); + + @WrapForJNI + public static native String encode(final byte[] data); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java new file mode 100644 index 0000000000..cc288d987f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/BasicSelectionActionDelegate.java @@ -0,0 +1,692 @@ +/* -*- 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.geckoview; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.RectF; +import android.os.Build; +import android.os.TransactionTooLargeException; +import android.text.TextUtils; +import android.util.Log; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Class that implements a basic SelectionActionDelegate. This class is used by GeckoView by default + * if the consumer does not explicitly set a SelectionActionDelegate. + * + * <p>To provide custom actions, extend this class and override the following methods, + * + * <p>1) Override {@link #getAllActions} to include custom action IDs in the returned array. This + * array must include all actions, available or not, and must not change over the class lifetime. + * + * <p>2) Override {@link #isActionAvailable} to return whether a custom action is currently + * available. + * + * <p>3) Override {@link #prepareAction} to set custom title and/or icon for a custom action. + * + * <p>4) Override {@link #performAction} to perform a custom action when used. + */ +@UiThread +public class BasicSelectionActionDelegate + implements ActionMode.Callback, GeckoSession.SelectionActionDelegate { + private static final String LOGTAG = "BasicSelectionAction"; + + protected static final String ACTION_PROCESS_TEXT = Intent.ACTION_PROCESS_TEXT; + + private static final String[] FLOATING_TOOLBAR_ACTIONS = + new String[] { + ACTION_CUT, + ACTION_COPY, + ACTION_PASTE, + ACTION_SELECT_ALL, + ACTION_PASTE_AS_PLAIN_TEXT, + ACTION_PROCESS_TEXT + }; + private static final String[] FIXED_TOOLBAR_ACTIONS = + new String[] {ACTION_SELECT_ALL, ACTION_CUT, ACTION_COPY, ACTION_PASTE}; + + // This is limitation of intent text. + private static final int MAX_INTENT_TEXT_LENGTH = 100000; + + protected final @NonNull Activity mActivity; + protected final boolean mUseFloatingToolbar; + + @Deprecated + @DeprecationSchedule(id = "selection-fission", version = 112) + protected final @NonNull Matrix mTempMatrix = new Matrix(); + + @Deprecated + @DeprecationSchedule(id = "selection-fission", version = 112) + protected final @NonNull RectF mTempRect = new RectF(); + + private boolean mExternalActionsEnabled; + + protected @Nullable ActionMode mActionMode; + protected @Nullable GeckoSession mSession; + protected @Nullable Selection mSelection; + protected boolean mRepopulatedMenu; + + private @Nullable ActionMode mActionModeForClipboardPermission; + + @TargetApi(Build.VERSION_CODES.M) + private class Callback2Wrapper extends ActionMode.Callback2 { + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionMode(actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onPrepareActionMode(actionMode, menu); + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + return BasicSelectionActionDelegate.this.onActionItemClicked(actionMode, menuItem); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + BasicSelectionActionDelegate.this.onDestroyActionMode(actionMode); + } + + @Override + public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { + super.onGetContentRect(mode, view, outRect); + BasicSelectionActionDelegate.this.onGetContentRect(mode, view, outRect); + } + } + + @SuppressWarnings("checkstyle:javadocmethod") + public BasicSelectionActionDelegate(final @NonNull Activity activity) { + this(activity, Build.VERSION.SDK_INT >= 23); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public BasicSelectionActionDelegate( + final @NonNull Activity activity, final boolean useFloatingToolbar) { + mActivity = activity; + mUseFloatingToolbar = useFloatingToolbar; + mExternalActionsEnabled = true; + } + + /** + * Set whether to include text actions from other apps in the floating toolbar. + * + * @param enable True if external actions should be enabled. + */ + public void enableExternalActions(final boolean enable) { + ThreadUtils.assertOnUiThread(); + mExternalActionsEnabled = enable; + + if (mActionMode != null) { + mActionMode.invalidate(); + } + } + + /** + * Get whether text actions from other apps are enabled. + * + * @return True if external actions are enabled. + */ + public boolean areExternalActionsEnabled() { + return mExternalActionsEnabled; + } + + /** + * Return list of all actions in proper order, regardless of their availability at present. + * Override to add to or remove from the default set. + * + * @return Array of action IDs in proper order. + */ + protected @NonNull String[] getAllActions() { + return mUseFloatingToolbar ? FLOATING_TOOLBAR_ACTIONS : FIXED_TOOLBAR_ACTIONS; + } + + /** + * Return whether an action is presently available. Override to indicate availability for custom + * actions. + * + * @param id Action ID. + * @return True if the action is presently available. + */ + protected boolean isActionAvailable(final @NonNull String id) { + if (mSelection == null) { + return false; + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O && ACTION_PASTE_AS_PLAIN_TEXT.equals(id)) { + return false; + } + + if (mExternalActionsEnabled && !mSelection.text.isEmpty() && ACTION_PROCESS_TEXT.equals(id)) { + return !getProcessTextExportedActivities().isEmpty(); + } + + return mSelection.isActionAvailable(id); + } + + /** + * Get exported activities for {@link BasicSelectionActionDelegate#ACTION_PROCESS_TEXT} when text + * is selected. + * + * @return list of exported activities + */ + private @NonNull List<ResolveInfo> getProcessTextExportedActivities() { + final PackageManager pm = mActivity.getPackageManager(); + final List<ResolveInfo> resolvedList = + pm.queryIntentActivityOptions( + null, null, getProcessTextIntent(null), PackageManager.MATCH_DEFAULT_ONLY); + final ArrayList<ResolveInfo> exportedList = new ArrayList<>(); + for (final ResolveInfo info : resolvedList) { + if (info.activityInfo.exported) { + exportedList.add(info); + } + } + + return exportedList; + } + + /** + * Provides access to whether there are text selection actions available. Override to indicate + * availability for custom actions. + * + * @return True if there are text selection actions available. + */ + public boolean isActionAvailable() { + if (mSelection == null) { + return false; + } + + return isActionAvailable(ACTION_PROCESS_TEXT) || !mSelection.availableActions.isEmpty(); + } + + /** + * Prepare a menu item corresponding to a certain action. Override to prepare menu item for custom + * action. + * + * @param id Action ID. + * @param item New menu item to prepare. + */ + protected void prepareAction(final @NonNull String id, final @NonNull MenuItem item) { + switch (id) { + case ACTION_CUT: + item.setTitle(android.R.string.cut); + break; + case ACTION_COPY: + item.setTitle(android.R.string.copy); + break; + case ACTION_PASTE: + item.setTitle(android.R.string.paste); + break; + case ACTION_PASTE_AS_PLAIN_TEXT: + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + throw new IllegalStateException("Unexpected version for action"); + } + item.setTitle(android.R.string.paste_as_plain_text); + break; + case ACTION_SELECT_ALL: + item.setTitle(android.R.string.selectAll); + break; + case ACTION_PROCESS_TEXT: + throw new IllegalStateException("Unexpected action"); + } + } + + /** + * Perform the specified action. Override to perform custom actions. + * + * @param id Action ID. + * @param item Nenu item for the action. + * @return True if the action was performed. + */ + protected boolean performAction(final @NonNull String id, final @NonNull MenuItem item) { + if (ACTION_PROCESS_TEXT.equals(id)) { + try { + mActivity.startActivity(item.getIntent()); + } catch (final ActivityNotFoundException e) { + Log.e(LOGTAG, "Cannot perform action", e); + return false; + } + return true; + } + + if (mSelection == null) { + return false; + } + mSelection.execute(id); + + // Android behavior is to clear selection on copy. + if (ACTION_COPY.equals(id)) { + if (mUseFloatingToolbar) { + clearSelection(); + } else { + mActionMode.finish(); + } + } + return true; + } + + /** + * Get the current selection object. This object should not be stored as it does not update when + * the selection becomes invalid. Stale actions are ignored. + * + * @return The {@link GeckoSession.SelectionActionDelegate.Selection} attached to the current + * action menu. <code>null</code> if no action menu is active. + */ + public @Nullable Selection getSelection() { + return mSelection; + } + + /** Clear the current selection, if possible. */ + public void clearSelection() { + if (mSelection == null) { + return; + } + + if (isActionAvailable(ACTION_COLLAPSE_TO_END)) { + mSelection.collapseToEnd(); + } else if (isActionAvailable(ACTION_UNSELECT)) { + mSelection.unselect(); + } else { + mSelection.hide(); + } + } + + private String getSelectedText(final int maxLength) { + if (mSelection == null) { + return ""; + } + + if (TextUtils.isEmpty(mSelection.text) || mSelection.text.length() < maxLength) { + return mSelection.text; + } + + return mSelection.text.substring(0, maxLength); + } + + private Intent getProcessTextIntent(@Nullable final ResolveInfo resolveInfo) { + final Intent intent = new Intent(Intent.ACTION_PROCESS_TEXT); + if (resolveInfo != null) { + intent.setComponent( + new ComponentName(resolveInfo.activityInfo.packageName, resolveInfo.activityInfo.name)); + } + intent.addCategory(Intent.CATEGORY_DEFAULT); + intent.setType("text/plain"); + // If using large text, anything intent may throw RemoteException. + intent.putExtra(Intent.EXTRA_PROCESS_TEXT, getSelectedText(MAX_INTENT_TEXT_LENGTH)); + // TODO: implement ability to replace text in Gecko for editable selection (bug 1453137). + intent.putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true); + return intent; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + ThreadUtils.assertOnUiThread(); + final String[] allActions = getAllActions(); + for (final String actionId : allActions) { + if (isActionAvailable(actionId)) { + if (!mUseFloatingToolbar && (Build.VERSION.SDK_INT == 22 || Build.VERSION.SDK_INT == 23)) { + // Android bug where onPrepareActionMode is not called initially. + onPrepareActionMode(actionMode, menu); + } + return true; + } + } + return false; + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + ThreadUtils.assertOnUiThread(); + final String[] allActions = getAllActions(); + boolean changed = false; + + // Whether we are repopulating an existing menu. + mRepopulatedMenu = menu.size() != 0; + + // For each action, see if it's available at present, and if necessary, + // add to or remove from menu. + for (int i = 0; i < allActions.length; i++) { + final String actionId = allActions[i]; + final int menuId = i + Menu.FIRST; + + if (ACTION_PROCESS_TEXT.equals(actionId)) { + if (mExternalActionsEnabled && mSelection != null && !mSelection.text.isEmpty()) { + final List<ResolveInfo> exportedPackageInfo = getProcessTextExportedActivities(); + if (!exportedPackageInfo.isEmpty()) { + for (final ResolveInfo info : exportedPackageInfo) { + final boolean isMenuItemAdded = addProcessTextMenuItem(menu, menuId, info); + if (isMenuItemAdded) { + changed = true; + } + } + } + } else if (menu.findItem(menuId) != null) { + menu.removeGroup(menuId); + changed = true; + } + continue; + } + + if (isActionAvailable(actionId)) { + if (menu.findItem(menuId) == null) { + prepareAction(actionId, menu.add(/* group */ Menu.NONE, menuId, menuId, /* title */ "")); + changed = true; + } + } else if (menu.findItem(menuId) != null) { + menu.removeItem(menuId); + changed = true; + } + } + return changed; + } + + private boolean addProcessTextMenuItem( + final Menu menu, final int menuId, final ResolveInfo info) { + boolean isMenuItemAdded = false; + try { + menu.addIntentOptions( + menuId, + menuId, + menuId, + mActivity.getComponentName(), + /* specifiec */ null, + getProcessTextIntent(info), + /* flags */ Menu.FLAG_APPEND_TO_GROUP, /* items */ + null); + isMenuItemAdded = true; + } catch (final RuntimeException e) { + if (e.getCause() instanceof TransactionTooLargeException) { + // Binder size error. MAX_INTENT_TEXT_LENGTH is still large? + Log.e(LOGTAG, "Cannot add intent option", e); + } else { + throw e; + } + } + return isMenuItemAdded; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + ThreadUtils.assertOnUiThread(); + MenuItem realMenuItem = null; + if (mRepopulatedMenu) { + // When we repopulate an existing menu, Android can sometimes give us an old, + // deleted MenuItem. Find the current MenuItem that corresponds to the old one. + final Menu menu = actionMode.getMenu(); + final int size = menu.size(); + for (int i = 0; i < size; i++) { + final MenuItem item = menu.getItem(i); + if (item == menuItem + || (item.getItemId() == menuItem.getItemId() + && item.getTitle().equals(menuItem.getTitle()))) { + realMenuItem = item; + break; + } + } + } else { + realMenuItem = menuItem; + } + + if (realMenuItem == null) { + return false; + } + final String[] allActions = getAllActions(); + return performAction(allActions[realMenuItem.getItemId() - Menu.FIRST], realMenuItem); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + ThreadUtils.assertOnUiThread(); + if (!mUseFloatingToolbar) { + clearSelection(); + } + mSession = null; + mSelection = null; + mActionMode = null; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public void onGetContentRect( + final @Nullable ActionMode mode, final @Nullable View view, final @NonNull Rect outRect) { + ThreadUtils.assertOnUiThread(); + if (mSelection == null || mSelection.screenRect == null) { + return; + } + + // mTempMatrix and mTempRect are deprecated. + mSession.getClientToScreenMatrix(mTempMatrix); + mTempMatrix.mapRect(mTempRect, mSelection.clientRect); + + mSelection.screenRect.roundOut(outRect); + } + + @TargetApi(Build.VERSION_CODES.M) + @Override + public void onShowActionRequest(final GeckoSession session, final Selection selection) { + ThreadUtils.assertOnUiThread(); + mSession = session; + mSelection = selection; + + if (mActionMode != null) { + if (isActionAvailable()) { + mActionMode.invalidate(); + } else { + mActionMode.finish(); + } + return; + } + + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + return; + } + + if (mUseFloatingToolbar) { + mActionMode = mActivity.startActionMode(new Callback2Wrapper(), ActionMode.TYPE_FLOATING); + } else { + mActionMode = mActivity.startActionMode(this); + } + } + + @Override + public void onHideAction(final GeckoSession session, final int reason) { + ThreadUtils.assertOnUiThread(); + if (mActionMode == null) { + return; + } + + switch (reason) { + case HIDE_REASON_ACTIVE_SCROLL: + case HIDE_REASON_ACTIVE_SELECTION: + case HIDE_REASON_INVISIBLE_SELECTION: + if (mUseFloatingToolbar) { + // Hide the floating toolbar when scrolling/selecting. + mActionMode.finish(); + } + break; + + case HIDE_REASON_NO_SELECTION: + mActionMode.finish(); + break; + } + } + + /** Callback class of clipboard permission. This is used on pre-M only */ + private class ClipboardPermissionCallback implements ActionMode.Callback { + private GeckoResult<AllowOrDeny> mResult; + + public ClipboardPermissionCallback(final GeckoResult<AllowOrDeny> result) { + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + } + + /** Callback class of clipboard permission for Android M+ */ + @TargetApi(Build.VERSION_CODES.M) + private class ClipboardPermissionCallbackM extends ActionMode.Callback2 { + private @Nullable GeckoResult<AllowOrDeny> mResult; + private final @NonNull GeckoSession mSession; + private final @Nullable Point mPoint; + + public ClipboardPermissionCallbackM( + final @NonNull GeckoSession session, + final @Nullable Point screenPoint, + final @NonNull GeckoResult<AllowOrDeny> result) { + mSession = session; + mPoint = screenPoint; + mResult = result; + } + + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + return BasicSelectionActionDelegate.this.onCreateActionModeForClipboardPermission( + actionMode, menu); + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return false; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + mResult.complete(AllowOrDeny.ALLOW); + mResult = null; + actionMode.finish(); + return true; + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + if (mResult != null) { + mResult.complete(AllowOrDeny.DENY); + } + BasicSelectionActionDelegate.this.onDestroyActionModeForClipboardPermission(actionMode); + } + + @Override + public void onGetContentRect(final ActionMode mode, final View view, final Rect outRect) { + super.onGetContentRect(mode, view, outRect); + + if (mPoint == null) { + return; + } + + outRect.set(mPoint.x, mPoint.y, mPoint.x + 1, mPoint.y + 1); + } + } + + /** + * Show action mode bar to request clipboard permission + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @TargetApi(Build.VERSION_CODES.M) + @Override + public GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest( + final GeckoSession session, final ClipboardPermission permission) { + ThreadUtils.assertOnUiThread(); + + final GeckoResult<AllowOrDeny> result = new GeckoResult<>(); + + if (mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + } + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + + if (mUseFloatingToolbar) { + mActionModeForClipboardPermission = + mActivity.startActionMode( + new ClipboardPermissionCallbackM(session, permission.screenPoint, result), + ActionMode.TYPE_FLOATING); + } else { + mActionModeForClipboardPermission = + mActivity.startActionMode(new ClipboardPermissionCallback(result)); + } + + return result; + } + + /** + * Dismiss action mode for requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @Override + public void onDismissClipboardPermissionRequest(final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + + if (mActionModeForClipboardPermission != null) { + mActionModeForClipboardPermission.finish(); + mActionModeForClipboardPermission = null; + } + } + + /* package */ boolean onCreateActionModeForClipboardPermission( + final ActionMode actionMode, final Menu menu) { + final MenuItem item = menu.add(/* group */ Menu.NONE, Menu.FIRST, Menu.FIRST, /* title */ ""); + item.setTitle(android.R.string.paste); + return true; + } + + /* package */ void onDestroyActionModeForClipboardPermission(final ActionMode actionMode) { + mActionModeForClipboardPermission = null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.java new file mode 100644 index 0000000000..9162566666 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CallbackResult.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.geckoview; + +import org.mozilla.gecko.util.EventCallback; + +/* package */ abstract class CallbackResult<T> extends GeckoResult<T> implements EventCallback { + @Override + public void sendError(final Object response) { + completeExceptionally( + response != null ? new Exception(response.toString()) : new UnknownError()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java new file mode 100644 index 0000000000..77bca329c4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CompositorController.java @@ -0,0 +1,133 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.graphics.Color; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.annotation.RobocopTarget; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public final class CompositorController { + private final GeckoSession.Compositor mCompositor; + + private List<Runnable> mDrawCallbacks; + private int mDefaultClearColor = Color.WHITE; + private Runnable mFirstPaintCallback; + + /* package */ CompositorController(final GeckoSession session) { + mCompositor = session.mCompositor; + } + + /* package */ void onCompositorReady() { + mCompositor.setDefaultClearColor(mDefaultClearColor); + mCompositor.enableLayerUpdateNotifications(mDrawCallbacks != null && !mDrawCallbacks.isEmpty()); + } + + /* package */ void onCompositorDetached() { + if (mDrawCallbacks != null) { + mDrawCallbacks.clear(); + } + } + + /* package */ void notifyDrawCallbacks() { + if (mDrawCallbacks != null) { + for (final Runnable callback : mDrawCallbacks) { + callback.run(); + } + } + } + + /** + * Add a callback to run when drawing (layer update) occurs. + * + * @param callback Callback to add. + */ + @RobocopTarget + public void addDrawCallback(final @NonNull Runnable callback) { + ThreadUtils.assertOnUiThread(); + + if (mDrawCallbacks == null) { + mDrawCallbacks = new ArrayList<Runnable>(2); + } + + if (mDrawCallbacks.add(callback) && mDrawCallbacks.size() == 1 && mCompositor.isReady()) { + mCompositor.enableLayerUpdateNotifications(true); + } + } + + /** + * Remove a previous draw callback. + * + * @param callback Callback to remove. + */ + @RobocopTarget + public void removeDrawCallback(final @NonNull Runnable callback) { + ThreadUtils.assertOnUiThread(); + + if (mDrawCallbacks == null) { + return; + } + + if (mDrawCallbacks.remove(callback) && mDrawCallbacks.isEmpty() && mCompositor.isReady()) { + mCompositor.enableLayerUpdateNotifications(false); + } + } + + /** + * Get the current clear color when drawing. + * + * @return Curent clear color. + */ + public int getClearColor() { + ThreadUtils.assertOnUiThread(); + return mDefaultClearColor; + } + + /** + * Set the clear color when drawing. Default is Color.WHITE. + * + * @param color Clear color. + */ + public void setClearColor(final int color) { + ThreadUtils.assertOnUiThread(); + + mDefaultClearColor = color; + if (mCompositor.isReady()) { + mCompositor.setDefaultClearColor(mDefaultClearColor); + } + } + + /** + * Get the current first paint callback. + * + * @return Current first paint callback or null if not set. + */ + public @Nullable Runnable getFirstPaintCallback() { + ThreadUtils.assertOnUiThread(); + return mFirstPaintCallback; + } + + /** + * Set a callback to run when a document is first drawn. + * + * @param callback First paint callback. + */ + public void setFirstPaintCallback(final @Nullable Runnable callback) { + ThreadUtils.assertOnUiThread(); + mFirstPaintCallback = callback; + } + + /* package */ void onFirstPaint() { + if (mFirstPaintCallback != null) { + mFirstPaintCallback.run(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java new file mode 100644 index 0000000000..fd06b2af48 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlocking.java @@ -0,0 +1,1656 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.mozilla.gecko.util.GeckoBundle; + +/** Content Blocking API to hold and control anti-tracking, cookie and Safe Browsing settings. */ +@AnyThread +public class ContentBlocking { + /** {@link SafeBrowsingProvider} configuration for Google's legacy SafeBrowsing server. */ + public static final SafeBrowsingProvider GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER = + SafeBrowsingProvider.withName("google") + .version("2.2") + .lists( + "goog-badbinurl-shavar", + "goog-downloadwhite-digest256", + "goog-phish-shavar", + "googpub-phish-shavar", + "goog-malware-shavar", + "goog-unwanted-shavar") + .updateUrl( + "https://safebrowsing.google.com/safebrowsing/downloads?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2&key=%GOOGLE_SAFEBROWSING_API_KEY%") + .getHashUrl( + "https://safebrowsing.google.com/safebrowsing/gethash?client=SAFEBROWSING_ID&appver=%MAJOR_VERSION%&pver=2.2") + .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=") + .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=") + .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=") + .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory") + .advisoryName("Google Safe Browsing") + .build(); + + /** {@link SafeBrowsingProvider} configuration for Google's SafeBrowsing server. */ + public static final SafeBrowsingProvider GOOGLE_SAFE_BROWSING_PROVIDER = + SafeBrowsingProvider.withName("google4") + .version("4") + .lists( + "goog-badbinurl-proto", + "goog-downloadwhite-proto", + "goog-phish-proto", + "googpub-phish-proto", + "goog-malware-proto", + "goog-unwanted-proto", + "goog-harmful-proto", + "goog-passwordwhite-proto") + .updateUrl( + "https://safebrowsing.googleapis.com/v4/threatListUpdates:fetch?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .getHashUrl( + "https://safebrowsing.googleapis.com/v4/fullHashes:find?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .reportUrl("https://safebrowsing.google.com/safebrowsing/diagnostic?site=") + .reportPhishingMistakeUrl("https://%LOCALE%.phish-error.mozilla.com/?url=") + .reportMalwareMistakeUrl("https://%LOCALE%.malware-error.mozilla.com/?url=") + .advisoryUrl("https://developers.google.com/safe-browsing/v4/advisory") + .advisoryName("Google Safe Browsing") + .dataSharingUrl( + "https://safebrowsing.googleapis.com/v4/threatHits?$ct=application/x-protobuf&key=%GOOGLE_SAFEBROWSING_API_KEY%&$httpMethod=POST") + .dataSharingEnabled(false) + .build(); + + // This class shouldn't be instantiated + protected ContentBlocking() {} + + @AnyThread + public static class Settings extends RuntimeSettings { + private final Map<String, SafeBrowsingProvider> mSafeBrowsingProviders = new HashMap<>(); + + private static final SafeBrowsingProvider[] DEFAULT_PROVIDERS = { + ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER, + ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER + }; + + @AnyThread + public static class Builder extends RuntimeSettings.Builder<Settings> { + @Override + protected @NonNull Settings newSettings(final @Nullable Settings settings) { + return new Settings(settings); + } + + /** + * Set custom safe browsing providers. + * + * @param providers one or more custom providers. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingProviders( + final @NonNull SafeBrowsingProvider... providers) { + getSettings().setSafeBrowsingProviders(providers); + return this; + } + + /** + * Set the safe browsing table for phishing threats. + * + * @param safeBrowsingPhishingTable one or more lists for safe browsing phishing. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingPhishingTable( + final @NonNull String[] safeBrowsingPhishingTable) { + getSettings().setSafeBrowsingPhishingTable(safeBrowsingPhishingTable); + return this; + } + + /** + * Set the safe browsing table for malware threats. + * + * @param safeBrowsingMalwareTable one or more lists for safe browsing malware. + * @return This Builder instance. + * @see SafeBrowsingProvider + */ + public @NonNull Builder safeBrowsingMalwareTable( + final @NonNull String[] safeBrowsingMalwareTable) { + getSettings().setSafeBrowsingMalwareTable(safeBrowsingMalwareTable); + return this; + } + + /** + * Set anti-tracking categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the + * {@link ContentBlocking.AntiTracking} flags. + * @return This Builder instance. + */ + public @NonNull Builder antiTracking(final @CBAntiTracking int cat) { + getSettings().setAntiTracking(cat); + return this; + } + + /** + * Set safe browsing categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the + * {@link ContentBlocking.SafeBrowsing} flags. + * @return This Builder instance. + */ + public @NonNull Builder safeBrowsing(final @CBSafeBrowsing int cat) { + getSettings().setSafeBrowsing(cat); + return this; + } + + /** + * Set cookie storage behavior. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return The Builder instance. + */ + public @NonNull Builder cookieBehavior(final @CBCookieBehavior int behavior) { + getSettings().setCookieBehavior(behavior); + return this; + } + + /** + * Set cookie storage behavior in private browsing mode. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return The Builder instance. + */ + public @NonNull Builder cookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) { + getSettings().setCookieBehaviorPrivateMode(behavior); + return this; + } + + /** + * Set the ETP behavior level. + * + * @param level The level of ETP blocking to use. Only takes effect if cookie behavior is set + * to {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * @return The Builder instance. + */ + public @NonNull Builder enhancedTrackingProtectionLevel(final @CBEtpLevel int level) { + getSettings().setEnhancedTrackingProtectionLevel(level); + return this; + } + + /** + * Set whether or not strict social tracking protection is enabled. This will block resources + * from loading if they are on the social tracking protection list, rather than just blocking + * cookies as with normal social tracking protection. + * + * @param enabled A boolean indicating whether or not strict social tracking protection should + * be enabled. + * @return The builder instance. + */ + public @NonNull Builder strictSocialTrackingProtection(final boolean enabled) { + getSettings().setStrictSocialTrackingProtection(enabled); + return this; + } + + /** + * Set whether or not to automatically purge tracking cookies. This will purge cookies from + * tracking sites that do not have recent user interaction provided that the cookie behavior + * is set to either {@link ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * + * @param enabled A boolean indicating whether or not cookie purging should be enabled. + * @return The builder instance. + */ + public @NonNull Builder cookiePurging(final boolean enabled) { + getSettings().setCookiePurging(enabled); + return this; + } + + /** + * Set the Cookie Banner Handling Mode. + * + * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerHandlingMode(final @CBCookieBannerMode int mode) { + getSettings().setCookieBannerMode(mode); + return this; + } + + /** + * Set the Cookie Banner Handling Mode for private browsing. + * + * @param mode The mode of the Cookie Banner Handling one of the {@link CBCookieBannerMode}. + * @return The Builder instance. + */ + public @NonNull Builder cookieBannerHandlingModePrivateBrowsing( + final @CBCookieBannerMode int mode) { + getSettings().setCookieBannerModePrivateBrowsing(mode); + return this; + } + } + + /* package */ final Pref<String> mAt = + new Pref<String>( + "urlclassifier.trackingTable", ContentBlocking.catToAtPref(AntiTracking.DEFAULT)); + /* package */ final Pref<Boolean> mCm = + new Pref<Boolean>("privacy.trackingprotection.cryptomining.enabled", false); + /* package */ final Pref<String> mCmList = + new Pref<String>( + "urlclassifier.features.cryptomining.blacklistTables", + ContentBlocking.catToCmListPref(AntiTracking.NONE)); + /* package */ final Pref<Boolean> mFp = + new Pref<Boolean>("privacy.trackingprotection.fingerprinting.enabled", false); + /* package */ final Pref<String> mFpList = + new Pref<String>( + "urlclassifier.features.fingerprinting.blacklistTables", + ContentBlocking.catToFpListPref(AntiTracking.NONE)); + /* package */ final Pref<Boolean> mSt = + new Pref<Boolean>("privacy.socialtracking.block_cookies.enabled", false); + /* package */ final Pref<Boolean> mStStrict = + new Pref<Boolean>("privacy.trackingprotection.socialtracking.enabled", false); + /* package */ final Pref<String> mStList = + new Pref<String>( + "urlclassifier.features.socialtracking.annotate.blacklistTables", + ContentBlocking.catToStListPref(AntiTracking.NONE)); + + /* package */ final Pref<Boolean> mSbMalware = + new Pref<Boolean>("browser.safebrowsing.malware.enabled", true); + /* package */ final Pref<Boolean> mSbPhishing = + new Pref<Boolean>("browser.safebrowsing.phishing.enabled", true); + /* package */ final Pref<Integer> mCookieBehavior = + new Pref<Integer>("network.cookie.cookieBehavior", CookieBehavior.ACCEPT_NON_TRACKERS); + /* package */ final Pref<Integer> mCookieBehaviorPrivateMode = + new Pref<Integer>( + "network.cookie.cookieBehavior.pbmode", CookieBehavior.ACCEPT_NON_TRACKERS); + /* package */ final Pref<Boolean> mCookiePurging = + new Pref<Boolean>("privacy.purge_trackers.enabled", false); + + /* package */ final Pref<Boolean> mEtpEnabled = + new Pref<Boolean>("privacy.trackingprotection.annotate_channels", false); + /* package */ final Pref<Boolean> mEtpStrict = + new Pref<Boolean>("privacy.annotate_channels.strict_list.enabled", false); + + /* package */ final Pref<Integer> mCbhMode = + new Pref<Integer>( + "cookiebanners.service.mode", CookieBannerMode.COOKIE_BANNER_MODE_DISABLED); + /* package */ final Pref<Integer> mCbhModePrivateBrowsing = + new Pref<Integer>( + "cookiebanners.service.mode.privateBrowsing", + CookieBannerMode.COOKIE_BANNER_MODE_REJECT); + + /* package */ final Pref<String> mSafeBrowsingMalwareTable = + new Pref<>( + "urlclassifier.malwareTable", + ContentBlocking.listsToPref( + "goog-malware-proto", + "goog-unwanted-proto", + "moztest-harmful-simple", + "moztest-malware-simple", + "moztest-unwanted-simple")); + /* package */ final Pref<String> mSafeBrowsingPhishingTable = + new Pref<>( + "urlclassifier.phishTable", + ContentBlocking.listsToPref( + // In official builds, we are allowed to use Google's private phishing + // list (see bug 1288840). + BuildConfig.MOZILLA_OFFICIAL ? "goog-phish-proto" : "googpub-phish-proto", + "moztest-phish-simple")); + + /** Construct default settings. */ + /* package */ Settings() { + this(null /* settings */); + } + + /** + * Copy-construct settings. + * + * @param settings Copy from this settings. + */ + /* package */ Settings(final @Nullable Settings settings) { + this(null /* parent */, settings); + } + + /** + * Copy-construct nested settings. + * + * @param parent The parent settings used for nesting. + * @param settings Copy from this settings. + */ + /* package */ Settings( + final @Nullable RuntimeSettings parent, final @Nullable Settings settings) { + super(parent); + + if (settings != null) { + updatePrefs(settings); + } else { + // Set default browsing providers + setSafeBrowsingProviders(DEFAULT_PROVIDERS); + } + } + + @Override + protected void updatePrefs(final @NonNull RuntimeSettings settings) { + super.updatePrefs(settings); + + final ContentBlocking.Settings source = (ContentBlocking.Settings) settings; + for (final SafeBrowsingProvider provider : source.mSafeBrowsingProviders.values()) { + mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider)); + } + } + + /** + * Get the collection of {@link SafeBrowsingProvider} for this runtime. + * + * @return an unmodifiable collection of {@link SafeBrowsingProvider} + * @see SafeBrowsingProvider + */ + public @NonNull Collection<SafeBrowsingProvider> getSafeBrowsingProviders() { + return Collections.unmodifiableCollection(mSafeBrowsingProviders.values()); + } + + /** + * Sets the collection of {@link SafeBrowsingProvider} for this runtime. + * + * <p>By default the collection is composed of {@link + * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER} and {@link + * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER}. + * + * @param providers {@link SafeBrowsingProvider} instances for this runtime. + * @return the {@link Settings} instance. + * @see SafeBrowsingProvider + */ + public @NonNull Settings setSafeBrowsingProviders( + final @NonNull SafeBrowsingProvider... providers) { + mSafeBrowsingProviders.clear(); + + for (final SafeBrowsingProvider provider : providers) { + mSafeBrowsingProviders.put(provider.getName(), new SafeBrowsingProvider(this, provider)); + } + + return this; + } + + /** + * Get the table for SafeBrowsing Phishing. The identifiers present in this table must match one + * of the identifiers present in {@link SafeBrowsingProvider#getLists}. + * + * @return an array of identifiers for SafeBrowsing's Phishing feature + * @see SafeBrowsingProvider.Builder#lists + */ + public @NonNull String[] getSafeBrowsingPhishingTable() { + return ContentBlocking.prefToLists(mSafeBrowsingPhishingTable.get()); + } + + /** + * Sets the table for SafeBrowsing Phishing. + * + * @param table an array of identifiers for SafeBrowsing's Phishing feature. + * @return this {@link Settings} instance. + * @see SafeBrowsingProvider.Builder#lists + */ + public @NonNull Settings setSafeBrowsingPhishingTable(final @NonNull String... table) { + mSafeBrowsingPhishingTable.commit(ContentBlocking.listsToPref(table)); + return this; + } + + /** + * Get the table for SafeBrowsing Malware. The identifiers present in this table must match one + * of the identifiers present in {@link SafeBrowsingProvider#getLists}. + * + * @return an array of identifiers for SafeBrowsing's Malware feature + * @see SafeBrowsingProvider.Builder#lists + */ + public @NonNull String[] getSafeBrowsingMalwareTable() { + return ContentBlocking.prefToLists(mSafeBrowsingMalwareTable.get()); + } + + /** + * Sets the table for SafeBrowsing Malware. + * + * @param table an array of identifiers for SafeBrowsing's Malware feature. + * @return this {@link Settings} instance. + * @see SafeBrowsingProvider.Builder#lists + */ + public @NonNull Settings setSafeBrowsingMalwareTable(final @NonNull String... table) { + mSafeBrowsingMalwareTable.commit(ContentBlocking.listsToPref(table)); + return this; + } + + /** + * Set anti-tracking categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the {@link + * ContentBlocking.AntiTracking} flags. + * @return This Settings instance. + */ + public @NonNull Settings setAntiTracking(final @CBAntiTracking int cat) { + mAt.commit(ContentBlocking.catToAtPref(cat)); + + mCm.commit(ContentBlocking.catToCmPref(cat)); + mCmList.commit(ContentBlocking.catToCmListPref(cat)); + + mFp.commit(ContentBlocking.catToFpPref(cat)); + mFpList.commit(ContentBlocking.catToFpListPref(cat)); + + mSt.commit(ContentBlocking.catToStPref(cat)); + mStList.commit(ContentBlocking.catToStListPref(cat)); + return this; + } + + /** + * Set the ETP behavior level. + * + * @param level The level of ETP blocking to use; must be one of {@link + * ContentBlocking.EtpLevel} flags. Only takes effect if the cookie behavior is {@link + * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * @return This Settings instance. + */ + public @NonNull Settings setEnhancedTrackingProtectionLevel(final @CBEtpLevel int level) { + mEtpEnabled.commit( + level == ContentBlocking.EtpLevel.DEFAULT || level == ContentBlocking.EtpLevel.STRICT); + mEtpStrict.commit(level == ContentBlocking.EtpLevel.STRICT); + return this; + } + + /** + * Set whether or not strict social tracking protection is enabled (ie, whether to block content + * or just cookies). Will only block if social tracking protection lists are supplied to {@link + * #setAntiTracking}. + * + * @param enabled A boolean indicating whether or not to enable strict social tracking + * protection. + * @return This Settings instance. + */ + public @NonNull Settings setStrictSocialTrackingProtection(final boolean enabled) { + mStStrict.commit(enabled); + return this; + } + + /** + * Set safe browsing categories. + * + * @param cat The categories of resources that should be blocked. Use one or more of the {@link + * ContentBlocking.SafeBrowsing} flags. + * @return This Settings instance. + */ + public @NonNull Settings setSafeBrowsing(final @CBSafeBrowsing int cat) { + mSbMalware.commit(ContentBlocking.catToSbMalware(cat)); + mSbPhishing.commit(ContentBlocking.catToSbPhishing(cat)); + return this; + } + + /** + * Get the set anti-tracking categories. + * + * @return The categories of resources to be blocked. + */ + public @CBAntiTracking int getAntiTrackingCategories() { + return ContentBlocking.atListToAtCat(mAt.get()) + | ContentBlocking.cmListToAtCat(mCmList.get()) + | ContentBlocking.fpListToAtCat(mFpList.get()) + | ContentBlocking.stListToAtCat(mStList.get()); + } + + /** + * Get the set ETP behavior level. + * + * @return The current ETP level; one of {@link ContentBlocking.EtpLevel}. + */ + public @CBEtpLevel int getEnhancedTrackingProtectionLevel() { + if (mEtpStrict.get()) { + return ContentBlocking.EtpLevel.STRICT; + } else if (mEtpEnabled.get()) { + return ContentBlocking.EtpLevel.DEFAULT; + } + return ContentBlocking.EtpLevel.NONE; + } + + /** + * Get whether or not strict social tracking protection is enabled. + * + * @return A boolean indicating whether or not strict social tracking protection is enabled. + */ + public boolean getStrictSocialTrackingProtection() { + return mStStrict.get(); + } + + /** + * Get the set safe browsing categories. + * + * @return The categories of resources to be blocked. + */ + public @CBSafeBrowsing int getSafeBrowsingCategories() { + return ContentBlocking.sbMalwareToSbCat(mSbMalware.get()) + | ContentBlocking.sbPhishingToSbCat(mSbPhishing.get()); + } + + /** + * Get the assigned cookie storage behavior. + * + * @return The assigned behavior, as one of {@link CookieBehavior} flags. + */ + @SuppressLint("WrongConstant") + public @CBCookieBehavior int getCookieBehavior() { + return mCookieBehavior.get(); + } + + /** + * Set cookie storage behavior. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBehavior(final @CBCookieBehavior int behavior) { + mCookieBehavior.commit(behavior); + return this; + } + + /** + * Get the assigned private mode cookie storage behavior. + * + * @return The assigned behavior, as one of {@link CookieBehavior} flags. + */ + @SuppressLint("WrongConstant") + public @CBCookieBehavior int getCookieBehaviorPrivateMode() { + return mCookieBehaviorPrivateMode.get(); + } + + /** + * Set cookie storage behavior for private browsing mode. + * + * @param behavior The storage behavior that should be applied. Use one of the {@link + * CookieBehavior} flags. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBehaviorPrivateMode(final @CBCookieBehavior int behavior) { + mCookieBehaviorPrivateMode.commit(behavior); + return this; + } + + /** + * Get whether or not cookie purging is enabled. + * + * @return A boolean indicating whether or not cookie purging is enabled. + */ + public boolean getCookiePurging() { + return mCookiePurging.get(); + } + + /** + * Enable or disable cookie purging. This will automatically purge cookies from tracking sites + * that have no recent user interaction, provided the cookie behavior is set to {@link + * ContentBlocking.CookieBehavior#ACCEPT_NON_TRACKERS} or {@link + * ContentBlocking.CookieBehavior#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS}. + * + * @param enabled A boolean indicating whether to enable cookie purging. + * @return This Settings instance. + */ + public @NonNull Settings setCookiePurging(final boolean enabled) { + mCookiePurging.commit(enabled); + return this; + } + + /** + * Set the Cookie Banner Handling Mode to the new provided {@link CBCookieBannerMode} value. + * + * @param mode Integer indicating the new mode. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerMode(final @CBCookieBannerMode int mode) { + mCbhMode.commit(mode); + return this; + } + + /** + * Gets the current cookie banner handling mode. + * + * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}. + */ + @SuppressLint("WrongConstant") + public @CBCookieBannerMode int getCookieBannerMode() { + return mCbhMode.get(); + } + + /** + * Set the Cookie Banner Handling Mode for private browsing to the new provided {@link + * CBCookieBannerMode} value. + * + * @param mode Integer indicating the new mode. + * @return This Settings instance. + */ + public @NonNull Settings setCookieBannerModePrivateBrowsing( + final @CBCookieBannerMode int mode) { + mCbhModePrivateBrowsing.commit(mode); + return this; + } + + /** + * Gets the current cookie banner handling mode for private browsing. + * + * @return int the current cookie banner handling mode, one of the {@link CBCookieBannerMode}. + */ + @SuppressLint("WrongConstant") + public @CBCookieBannerMode int getCookieBannerModePrivateBrowsing() { + return mCbhModePrivateBrowsing.get(); + } + + public static final Parcelable.Creator<Settings> CREATOR = + new Parcelable.Creator<Settings>() { + @Override + public Settings createFromParcel(final Parcel in) { + final Settings settings = new Settings(); + settings.readFromParcel(in); + return settings; + } + + @Override + public Settings[] newArray(final int size) { + return new Settings[size]; + } + }; + } + + /** + * Holds configuration for a SafeBrowsing provider. <br> + * <br> + * This class can be used to modify existing configuration for SafeBrowsing providers or to add a + * custom SafeBrowsing provider to the app. <br> + * <br> + * Default configuration for Google's SafeBrowsing servers can be found at {@link + * ContentBlocking#GOOGLE_SAFE_BROWSING_PROVIDER} and {@link + * ContentBlocking#GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER}. <br> + * <br> + * This class is immutable, once constructed its values cannot be changed. <br> + * <br> + * You can, however, use the {@link #from} method to build upon an existing configuration. For + * example to override the Google's server configuration, you can do the following: <br> + * + * <pre><code> + * SafeBrowsingProvider override = SafeBrowsingProvider + * .from(ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER) + * .getHashUrl("http://my-custom-server.com/...") + * .updateUrl("http://my-custom-server.com/...") + * .build(); + * + * runtime.getContentBlocking().setSafeBrowsingProviders(override); + * </code></pre> + * + * This will override the configuration. <br> + * <br> + * You can also add a custom SafeBrowsing provider using the {@link #withName} method. For + * example, to add a custom provider that provides the list <code>testprovider-phish-digest256 + * </code> do the following: <br> + * + * <pre><code> + * SafeBrowsingProvider custom = SafeBrowsingProvider + * .withName("custom-provider") + * .version("2.2") + * .lists("testprovider-phish-digest256") + * .updateUrl("http://my-custom-server2.com/...") + * .getHashUrl("http://my-custom-server2.com/...") + * .build(); + * </code></pre> + * + * And then add the custom provider (adding optionally existing providers): <br> + * + * <pre><code> + * runtime.getContentBlocking().setSafeBrowsingProviders( + * custom, + * // Add this if you want to keep the existing configuration too. + * ContentBlocking.GOOGLE_SAFE_BROWSING_PROVIDER, + * ContentBlocking.GOOGLE_LEGACY_SAFE_BROWSING_PROVIDER); + * </code></pre> + * + * And set the list in the phishing configuration <br> + * + * <pre><code> + * runtime.getContentBlocking().setSafeBrowsingPhishingTable( + * "testprovider-phish-digest256", + * // Existing configuration + * "goog-phish-proto"); + * </code></pre> + * + * Note that any list present in the phishing or malware tables need to appear in one safe + * browsing provider's {@link #getLists} property. + * + * <p>See also <a href="https://developers.google.com/safe-browsing/v4">safe-browsing/v4</a>. + */ + @AnyThread + public static class SafeBrowsingProvider extends RuntimeSettings { + private static final String ROOT = "browser.safebrowsing.provider."; + + private final String mName; + + /* package */ final Pref<String> mVersion; + /* package */ final Pref<String> mLists; + /* package */ final Pref<String> mUpdateUrl; + /* package */ final Pref<String> mGetHashUrl; + /* package */ final Pref<String> mReportUrl; + /* package */ final Pref<String> mReportPhishingMistakeUrl; + /* package */ final Pref<String> mReportMalwareMistakeUrl; + /* package */ final Pref<String> mAdvisoryUrl; + /* package */ final Pref<String> mAdvisoryName; + /* package */ final Pref<String> mDataSharingUrl; + /* package */ final Pref<Boolean> mDataSharingEnabled; + + /** + * Creates a {@link SafeBrowsingProvider.Builder} for a provider with the given name. + * + * <p>Note: the <code>mozilla</code> name is reserved for internal use, and this method will + * throw if you attempt to build a provider with that name. + * + * @param name The name of the provider. + * @return a {@link Builder} instance that can be used to build a provider. + * @throws IllegalArgumentException if this method is called with <code>name="mozilla"</code> + */ + @NonNull + public static Builder withName(final @NonNull String name) { + if ("mozilla".equals(name)) { + throw new IllegalArgumentException("The 'mozilla' name is reserved for internal use."); + } + return new Builder(name); + } + + /** + * Creates a {@link SafeBrowsingProvider.Builder} based on the given provider. + * + * <p>All properties not otherwise specified will be copied from the provider given in input. + * + * @param provider The source provider for this builder. + * @return a {@link Builder} instance that can be used to create a configuration based on the + * builder in input. + */ + @NonNull + public static Builder from(final @NonNull SafeBrowsingProvider provider) { + return new Builder(provider); + } + + @AnyThread + public static class Builder { + final SafeBrowsingProvider mProvider; + + private Builder(final String name) { + mProvider = new SafeBrowsingProvider(name); + } + + private Builder(final SafeBrowsingProvider source) { + mProvider = new SafeBrowsingProvider(source); + } + + /** + * Sets the SafeBrowsing protocol session for this provider. + * + * @param version the version strong, e.g. "2.2" or "4". + * @return this {@link Builder} instance. + */ + public @NonNull Builder version(final @NonNull String version) { + mProvider.mVersion.set(version); + return this; + } + + /** + * Sets the lists provided by this provider. + * + * @param lists one or more lists for this provider, e.g. "goog-malware-proto", + * "goog-unwanted-proto" + * @return this {@link Builder} instance. + */ + public @NonNull Builder lists(final @NonNull String... lists) { + mProvider.mLists.set(ContentBlocking.listsToPref(lists)); + return this; + } + + /** + * Sets the url that will be used to update the threat list for this provider. + * + * <p>See also <a + * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch"> + * v4/threadListUpdates/fetch </a>. + * + * @param updateUrl the update url endpoint for this provider + * @return this {@link Builder} instance. + */ + public @NonNull Builder updateUrl(final @NonNull String updateUrl) { + mProvider.mUpdateUrl.set(updateUrl); + return this; + } + + /** + * Sets the url that will be used to get the full hashes that match a partial hash. + * + * <p>See also <a + * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find"> + * v4/fullHashes/find </a>. + * + * @param getHashUrl the gethash url endpoint for this provider + * @return this {@link Builder} instance. + */ + public @NonNull Builder getHashUrl(final @NonNull String getHashUrl) { + mProvider.mGetHashUrl.set(getHashUrl); + return this; + } + + /** + * Set the url that will be used to report a url to the SafeBrowsing provider. + * + * @param reportUrl the url endpoint to report a url to this provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder reportUrl(final @NonNull String reportUrl) { + mProvider.mReportUrl.set(reportUrl); + return this; + } + + /** + * Set the url that will be used to report a url mistakenly reported as Phishing to the + * SafeBrowsing provider. + * + * @param reportPhishingMistakeUrl the url endpoint to report a url to this provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder reportPhishingMistakeUrl( + final @NonNull String reportPhishingMistakeUrl) { + mProvider.mReportPhishingMistakeUrl.set(reportPhishingMistakeUrl); + return this; + } + + /** + * Set the url that will be used to report a url mistakenly reported as Malware to the + * SafeBrowsing provider. + * + * @param reportMalwareMistakeUrl the url endpoint to report a url to this provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder reportMalwareMistakeUrl( + final @NonNull String reportMalwareMistakeUrl) { + mProvider.mReportMalwareMistakeUrl.set(reportMalwareMistakeUrl); + return this; + } + + /** + * Set the url that will be used to give a general advisory about this SafeBrowsing provider. + * + * @param advisoryUrl the adivisory page url for this provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder advisoryUrl(final @NonNull String advisoryUrl) { + mProvider.mAdvisoryUrl.set(advisoryUrl); + return this; + } + + /** + * Set the advisory name for this provider. + * + * @param advisoryName the adivisory name for this provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder advisoryName(final @NonNull String advisoryName) { + mProvider.mAdvisoryName.set(advisoryName); + return this; + } + + /** + * Set url to share threat data to the provider, if enabled by {@link #dataSharingEnabled}. + * + * @param dataSharingUrl the url endpoint + * @return this {@link Builder} instance. + */ + public @NonNull Builder dataSharingUrl(final @NonNull String dataSharingUrl) { + mProvider.mDataSharingUrl.set(dataSharingUrl); + return this; + } + + /** + * Set whether to share threat data with the provider, off by default. + * + * @param dataSharingEnabled <code>true</code> if the browser should share threat data with + * the provider. + * @return this {@link Builder} instance. + */ + public @NonNull Builder dataSharingEnabled(final boolean dataSharingEnabled) { + mProvider.mDataSharingEnabled.set(dataSharingEnabled); + return this; + } + + /** + * Build the {@link SafeBrowsingProvider} based on this {@link Builder} instance. + * + * @return thie {@link SafeBrowsingProvider} instance. + */ + public @NonNull SafeBrowsingProvider build() { + return new SafeBrowsingProvider(mProvider); + } + } + + /* package */ SafeBrowsingProvider(final SafeBrowsingProvider source) { + this(/* name */ null, /* parent */ null, source); + } + + /* package */ SafeBrowsingProvider( + final RuntimeSettings parent, final SafeBrowsingProvider source) { + this(/* name */ null, parent, source); + } + + /* package */ SafeBrowsingProvider(final String name) { + this(name, /* parent */ null, /* source */ null); + } + + /* package */ SafeBrowsingProvider( + final String name, final RuntimeSettings parent, final SafeBrowsingProvider source) { + super(parent); + + if (name != null) { + mName = name; + } else if (source != null) { + mName = source.mName; + } else { + throw new IllegalArgumentException("Either name or source must be non-null"); + } + + mVersion = new Pref<>(ROOT + mName + ".pver", null); + mLists = new Pref<>(ROOT + mName + ".lists", null); + mUpdateUrl = new Pref<>(ROOT + mName + ".updateURL", null); + mGetHashUrl = new Pref<>(ROOT + mName + ".gethashURL", null); + mReportUrl = new Pref<>(ROOT + mName + ".reportURL", null); + mReportPhishingMistakeUrl = new Pref<>(ROOT + mName + ".reportPhishMistakeURL", null); + mReportMalwareMistakeUrl = new Pref<>(ROOT + mName + ".reportMalwareMistakeURL", null); + mAdvisoryUrl = new Pref<>(ROOT + mName + ".advisoryURL", null); + mAdvisoryName = new Pref<>(ROOT + mName + ".advisoryName", null); + mDataSharingUrl = new Pref<>(ROOT + mName + ".dataSharingURL", null); + mDataSharingEnabled = new Pref<>(ROOT + mName + ".dataSharing.enabled", false); + + if (source != null) { + updatePrefs(source); + } + } + + /** + * Get the name of this provider. + * + * @return a string containing the name. + */ + public @NonNull String getName() { + return mName; + } + + /** + * Get the version for this provider. + * + * @return a string representing the version, e.g. "2.2" or "4". + */ + public @Nullable String getVersion() { + return mVersion.get(); + } + + /** + * Get the lists provided by this provider. + * + * @return an array of string identifiers for the lists + */ + public @NonNull String[] getLists() { + return ContentBlocking.prefToLists(mLists.get()); + } + + /** + * Get the url that will be used to update the threat list for this provider. + * + * <p>See also <a + * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/threatListUpdates/fetch"> + * v4/threadListUpdates/fetch </a>. + * + * @return a string containing the URL. + */ + public @Nullable String getUpdateUrl() { + return mUpdateUrl.get(); + } + + /** + * Get the url that will be used to get the full hashes that match a partial hash. + * + * <p>See also <a + * href="https://developers.google.com/safe-browsing/v4/reference/rest/v4/fullHashes/find"> + * v4/fullHashes/find </a>. + * + * @return a string containing the URL. + */ + public @Nullable String getGetHashUrl() { + return mGetHashUrl.get(); + } + + /** + * Get the url that will be used to report a url to the SafeBrowsing provider. + * + * @return a string containing the URL. + */ + public @Nullable String getReportUrl() { + return mReportUrl.get(); + } + + /** + * Get the url that will be used to report a url mistakenly reported as Phishing to the + * SafeBrowsing provider. + * + * @return a string containing the URL. + */ + public @Nullable String getReportPhishingMistakeUrl() { + return mReportPhishingMistakeUrl.get(); + } + + /** + * Get the url that will be used to report a url mistakenly reported as Malware to the + * SafeBrowsing provider. + * + * @return a string containing the URL. + */ + public @Nullable String getReportMalwareMistakeUrl() { + return mReportMalwareMistakeUrl.get(); + } + + /** + * Get the url that will be used to give a general advisory about this SafeBrowsing provider. + * + * @return a string containing the URL. + */ + public @Nullable String getAdvisoryUrl() { + return mAdvisoryUrl.get(); + } + + /** + * Get the advisory name for this provider. + * + * @return a string containing the URL. + */ + public @Nullable String getAdvisoryName() { + return mAdvisoryName.get(); + } + + /** + * Get the url to share threat data to the provider, if enabled by {@link + * #getDataSharingEnabled}. + * + * @return this {@link Builder} instance. + */ + public @Nullable String getDataSharingUrl() { + return mDataSharingUrl.get(); + } + + /** + * Get whether to share threat data with the provider. + * + * @return <code>true</code> if the browser should whare threat data with the provider, <code> + * false</code> otherwise. + */ + public @Nullable Boolean getDataSharingEnabled() { + return mDataSharingEnabled.get(); + } + + @Override // Parcelable + @AnyThread + public void writeToParcel(final Parcel out, final int flags) { + out.writeValue(mName); + super.writeToParcel(out, flags); + } + + /** Creator instance for this class. */ + public static final Parcelable.Creator<SafeBrowsingProvider> CREATOR = + new Parcelable.Creator<SafeBrowsingProvider>() { + @Override + public SafeBrowsingProvider createFromParcel(final Parcel source) { + final String name = (String) source.readValue(getClass().getClassLoader()); + final SafeBrowsingProvider settings = new SafeBrowsingProvider(name); + settings.readFromParcel(source); + return settings; + } + + @Override + public SafeBrowsingProvider[] newArray(final int size) { + return new SafeBrowsingProvider[size]; + } + }; + } + + private static String listsToPref(final String... lists) { + final StringBuilder prefBuilder = new StringBuilder(); + + for (final String list : lists) { + if (list.contains(",")) { + // We use ',' as the separator, so the list name cannot contain it. + // Should never happen. + throw new IllegalArgumentException("List name cannot contain ',' character."); + } + + prefBuilder.append(list); + prefBuilder.append(","); + } + + // Remove trailing "," + if (lists.length > 0) { + prefBuilder.setLength(prefBuilder.length() - 1); + } + + return prefBuilder.toString(); + } + + private static String[] prefToLists(final String pref) { + return pref != null ? pref.split(",") : new String[] {}; + } + + public static class AntiTracking { + public static final int NONE = 0; + + /** Block advertisement trackers. */ + public static final int AD = 1 << 1; + + /** Block analytics trackers. */ + public static final int ANALYTIC = 1 << 2; + + /** + * Block social trackers. Note: This is not the same as "Social Tracking Protection", which is + * controlled by {@link #STP}. + */ + public static final int SOCIAL = 1 << 3; + + /** Block content trackers. May cause issues with some web sites. */ + public static final int CONTENT = 1 << 4; + + /** Block Gecko test trackers (used for tests). */ + public static final int TEST = 1 << 5; + + /** Block cryptocurrency miners. */ + public static final int CRYPTOMINING = 1 << 6; + + /** Block fingerprinting trackers. */ + public static final int FINGERPRINTING = 1 << 7; + + /** Block trackers on the Social Tracking Protection list. */ + public static final int STP = 1 << 8; + + /** Block ad, analytic, social and test trackers. */ + public static final int DEFAULT = AD | ANALYTIC | SOCIAL | TEST; + + /** Block all known trackers. May cause issues with some web sites. */ + public static final int STRICT = DEFAULT | CONTENT | CRYPTOMINING | FINGERPRINTING; + + protected AntiTracking() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + AntiTracking.AD, + AntiTracking.ANALYTIC, + AntiTracking.SOCIAL, + AntiTracking.CONTENT, + AntiTracking.TEST, + AntiTracking.CRYPTOMINING, + AntiTracking.FINGERPRINTING, + AntiTracking.DEFAULT, + AntiTracking.STRICT, + AntiTracking.STP, + AntiTracking.NONE + }) + public @interface CBAntiTracking {} + + public static class SafeBrowsing { + public static final int NONE = 0; + + /** Block malware sites. */ + public static final int MALWARE = 1 << 10; + + /** Block unwanted sites. */ + public static final int UNWANTED = 1 << 11; + + /** Block harmful sites. */ + public static final int HARMFUL = 1 << 12; + + /** Block phishing sites. */ + public static final int PHISHING = 1 << 13; + + /** Block all unsafe sites. */ + public static final int DEFAULT = MALWARE | UNWANTED | HARMFUL | PHISHING; + + protected SafeBrowsing() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SafeBrowsing.MALWARE, SafeBrowsing.UNWANTED, + SafeBrowsing.HARMFUL, SafeBrowsing.PHISHING, + SafeBrowsing.DEFAULT, SafeBrowsing.NONE + }) + public @interface CBSafeBrowsing {} + + // Sync values with nsICookieService.idl. + public static class CookieBehavior { + /** Accept first-party and third-party cookies and site data. */ + public static final int ACCEPT_ALL = 0; + + /** + * Accept only first-party cookies and site data to block cookies which are not associated with + * the domain of the visited site. + */ + public static final int ACCEPT_FIRST_PARTY = 1; + + /** Do not store any cookies and site data. */ + public static final int ACCEPT_NONE = 2; + + /** + * Accept first-party and third-party cookies and site data only from sites previously visited + * in a first-party context. + */ + public static final int ACCEPT_VISITED = 3; + + /** + * Accept only first-party and non-tracking third-party cookies and site data to block cookies + * which are not associated with the domain of the visited site set by known trackers. + */ + public static final int ACCEPT_NON_TRACKERS = 4; + + /** + * Enable dynamic first party isolation (dFPI); this will block third-party tracking cookies in + * accordance with the ETP level and isolate non-tracking third-party cookies. + */ + public static final int ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS = 5; + + protected CookieBehavior() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CookieBehavior.ACCEPT_ALL, CookieBehavior.ACCEPT_FIRST_PARTY, + CookieBehavior.ACCEPT_NONE, CookieBehavior.ACCEPT_VISITED, + CookieBehavior.ACCEPT_NON_TRACKERS + }) + public @interface CBCookieBehavior {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({EtpLevel.NONE, EtpLevel.DEFAULT, EtpLevel.STRICT}) + public @interface CBEtpLevel {} + + /** Possible settings for ETP. */ + public static class EtpLevel { + /** Do not enable ETP at all. */ + public static final int NONE = 0; + + /** Enable ETP for ads, analytic, and social tracking lists. */ + public static final int DEFAULT = 1; + + /** + * Enable ETP for all of the default lists as well as the content list. May break many sites! + */ + public static final int STRICT = 2; + } + + /** Holds content block event details. */ + public static class BlockEvent { + /** The URI of the blocked resource. */ + public final @NonNull String uri; + + private final @CBAntiTracking int mAntiTrackingCat; + private final @CBSafeBrowsing int mSafeBrowsingCat; + private final @CBCookieBehavior int mCookieBehaviorCat; + private final boolean mIsBlocking; + + @SuppressWarnings("checkstyle:javadocmethod") + public BlockEvent( + @NonNull final String uri, + final @CBAntiTracking int atCat, + final @CBSafeBrowsing int sbCat, + final @CBCookieBehavior int cbCat, + final boolean isBlocking) { + this.uri = uri; + this.mAntiTrackingCat = atCat; + this.mSafeBrowsingCat = sbCat; + this.mCookieBehaviorCat = cbCat; + this.mIsBlocking = isBlocking; + } + + /** + * The anti-tracking category types of the blocked resource. + * + * @return One or more of the {@link AntiTracking} flags. + */ + @UiThread + public @CBAntiTracking int getAntiTrackingCategory() { + return mAntiTrackingCat; + } + + /** + * The safe browsing category types of the blocked resource. + * + * @return One or more of the {@link SafeBrowsing} flags. + */ + @UiThread + public @CBSafeBrowsing int getSafeBrowsingCategory() { + return mSafeBrowsingCat; + } + + /** + * The cookie types of the blocked resource. + * + * @return One or more of the {@link CookieBehavior} flags. + */ + @UiThread + public @CBCookieBehavior int getCookieBehaviorCategory() { + return mCookieBehaviorCat; + } + + /* package */ static BlockEvent fromBundle(@NonNull final GeckoBundle bundle) { + final String uri = bundle.getString("uri"); + final String blockedList = bundle.getString("blockedList"); + final String loadedList = TextUtils.join(",", bundle.getStringArray("loadedLists")); + final long error = bundle.getLong("error", 0L); + final long category = bundle.getLong("category", 0L); + + final String matchedList = blockedList != null ? blockedList : loadedList; + + // Note: Even if loadedList is non-empty it does not necessarily + // mean that the event is not a blocking event. + final boolean blocking = + (blockedList != null || error != 0L || ContentBlocking.isBlockingGeckoCbCat(category)); + + return new BlockEvent( + uri, + ContentBlocking.atListToAtCat(matchedList) + | ContentBlocking.cmListToAtCat(matchedList) + | ContentBlocking.fpListToAtCat(matchedList) + | ContentBlocking.stListToAtCat(matchedList), + ContentBlocking.errorToSbCat(error), + ContentBlocking.geckoCatToCbCat(category), + blocking); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public boolean isBlocking() { + return mIsBlocking; + } + } + + /** GeckoSession applications implement this interface to handle content blocking events. */ + public interface Delegate { + /** + * A content element has been blocked from loading. Set blocked element categories via {@link + * GeckoRuntimeSettings} and enable content blocking via {@link GeckoSessionSettings}. + * + * @param session The GeckoSession that initiated the callback. + * @param event The {@link BlockEvent} details. + */ + @UiThread + default void onContentBlocked( + @NonNull final GeckoSession session, @NonNull final BlockEvent event) {} + + /** + * A content element that could be blocked has been loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param event The {@link BlockEvent} details. + */ + @UiThread + default void onContentLoaded( + @NonNull final GeckoSession session, @NonNull final BlockEvent event) {} + } + + private static final String TEST = "moztest-track-simple"; + private static final String AD = "ads-track-digest256"; + private static final String ANALYTIC = "analytics-track-digest256"; + private static final String SOCIAL = "social-track-digest256"; + private static final String CONTENT = "content-track-digest256"; + private static final String CRYPTOMINING = "base-cryptomining-track-digest256"; + private static final String FINGERPRINTING = "base-fingerprinting-track-digest256"; + private static final String STP = + "social-tracking-protection-facebook-digest256,social-tracking-protection-linkedin-digest256,social-tracking-protection-twitter-digest256"; + + /* package */ static @CBSafeBrowsing int sbMalwareToSbCat(final boolean enabled) { + return enabled + ? (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL) + : SafeBrowsing.NONE; + } + + /* package */ static @CBSafeBrowsing int sbPhishingToSbCat(final boolean enabled) { + return enabled ? SafeBrowsing.PHISHING : SafeBrowsing.NONE; + } + + /* package */ static boolean catToSbMalware(@CBAntiTracking final int cat) { + return (cat & (SafeBrowsing.MALWARE | SafeBrowsing.UNWANTED | SafeBrowsing.HARMFUL)) != 0; + } + + /* package */ static boolean catToSbPhishing(@CBAntiTracking final int cat) { + return (cat & SafeBrowsing.PHISHING) != 0; + } + + /* package */ static String catToAtPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.TEST) != 0) { + builder.append(TEST).append(','); + } + if ((cat & AntiTracking.AD) != 0) { + builder.append(AD).append(','); + } + if ((cat & AntiTracking.ANALYTIC) != 0) { + builder.append(ANALYTIC).append(','); + } + if ((cat & AntiTracking.SOCIAL) != 0) { + builder.append(SOCIAL).append(','); + } + if ((cat & AntiTracking.CONTENT) != 0) { + builder.append(CONTENT).append(','); + } + if (builder.length() == 0) { + return ""; + } + // Trim final ','. + return builder.substring(0, builder.length() - 1); + } + + /* package */ static boolean catToCmPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.CRYPTOMINING) != 0; + } + + /* package */ static String catToCmListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.CRYPTOMINING) != 0) { + builder.append(CRYPTOMINING); + } + return builder.toString(); + } + + /* package */ static boolean catToFpPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.FINGERPRINTING) != 0; + } + + /* package */ static String catToFpListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.FINGERPRINTING) != 0) { + builder.append(FINGERPRINTING); + } + return builder.toString(); + } + + /* package */ static @CBAntiTracking int fpListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(FINGERPRINTING) != -1) { + cat |= AntiTracking.FINGERPRINTING; + } + return cat; + } + + /* package */ static boolean catToStPref(@CBAntiTracking final int cat) { + return (cat & AntiTracking.STP) != 0; + } + + /* package */ static String catToStListPref(@CBAntiTracking final int cat) { + final StringBuilder builder = new StringBuilder(); + + if ((cat & AntiTracking.STP) != 0) { + builder.append(STP).append(","); + } + if (builder.length() == 0) { + return ""; + } + // Trim final ','. + return builder.substring(0, builder.length() - 1); + } + + /* package */ static @CBAntiTracking int atListToAtCat(final String list) { + int cat = AntiTracking.NONE; + + if (list == null) { + return cat; + } + if (list.indexOf(TEST) != -1) { + cat |= AntiTracking.TEST; + } + if (list.indexOf(AD) != -1) { + cat |= AntiTracking.AD; + } + if (list.indexOf(ANALYTIC) != -1) { + cat |= AntiTracking.ANALYTIC; + } + if (list.indexOf(SOCIAL) != -1) { + cat |= AntiTracking.SOCIAL; + } + if (list.indexOf(CONTENT) != -1) { + cat |= AntiTracking.CONTENT; + } + return cat; + } + + /* package */ static @CBAntiTracking int cmListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(CRYPTOMINING) != -1) { + cat |= AntiTracking.CRYPTOMINING; + } + return cat; + } + + /* package */ static @CBAntiTracking int stListToAtCat(final String list) { + int cat = AntiTracking.NONE; + if (list == null) { + return cat; + } + if (list.indexOf(STP) != -1) { + cat |= AntiTracking.STP; + } + return cat; + } + + /* package */ static @CBSafeBrowsing int errorToSbCat(final long error) { + // Match flags with XPCOM ErrorList.h. + if (error == 0x805D001FL) { + return SafeBrowsing.PHISHING; + } + if (error == 0x805D001EL) { + return SafeBrowsing.MALWARE; + } + if (error == 0x805D0023L) { + return SafeBrowsing.UNWANTED; + } + if (error == 0x805D0026L) { + return SafeBrowsing.HARMFUL; + } + return SafeBrowsing.NONE; + } + + // Match flags with nsIWebProgressListener.idl. + private static final long STATE_COOKIES_LOADED = 0x8000L; + private static final long STATE_COOKIES_LOADED_TRACKER = 0x40000L; + private static final long STATE_COOKIES_LOADED_SOCIALTRACKER = 0x80000L; + private static final long STATE_COOKIES_BLOCKED_TRACKER = 0x20000000L; + private static final long STATE_COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000L; + private static final long STATE_COOKIES_BLOCKED_ALL = 0x40000000L; + private static final long STATE_COOKIES_BLOCKED_FOREIGN = 0x80L; + + /* package */ static boolean isBlockingGeckoCbCat(final long geckoCat) { + return (geckoCat + & (STATE_COOKIES_BLOCKED_TRACKER + | STATE_COOKIES_BLOCKED_SOCIALTRACKER + | STATE_COOKIES_BLOCKED_ALL + | STATE_COOKIES_BLOCKED_FOREIGN)) + != 0; + } + + /* package */ static @CBCookieBehavior int geckoCatToCbCat(final long geckoCat) { + if ((geckoCat & STATE_COOKIES_LOADED) != 0) { + // We don't know which setting would actually block this cookie, so + // we return the most strict value. + return CookieBehavior.ACCEPT_NONE; + } + if ((geckoCat & STATE_COOKIES_BLOCKED_FOREIGN) != 0) { + return CookieBehavior.ACCEPT_FIRST_PARTY; + } + // If we receive STATE_COOKIES_LOADED_{SOCIAL,}TRACKER we know that this + // setting would block this cookie. + if ((geckoCat + & (STATE_COOKIES_BLOCKED_TRACKER + | STATE_COOKIES_BLOCKED_SOCIALTRACKER + | STATE_COOKIES_LOADED_TRACKER + | STATE_COOKIES_LOADED_SOCIALTRACKER)) + != 0) { + return CookieBehavior.ACCEPT_NON_TRACKERS; + } + if ((geckoCat & STATE_COOKIES_BLOCKED_ALL) != 0) { + return CookieBehavior.ACCEPT_NONE; + } + // TODO: There are more reasons why cookies may be blocked. + return CookieBehavior.ACCEPT_ALL; + } + + // Cookie Banner Handling feature. + + public static class CookieBannerMode { + /** Do not enable handling cookie banners. */ + public static final int COOKIE_BANNER_MODE_DISABLED = 0; + + /** Only handle banners where selecting "reject all" is possible. */ + public static final int COOKIE_BANNER_MODE_REJECT = 1; + + /** Reject cookies when possible otherwise accept the cookies. */ + public static final int COOKIE_BANNER_MODE_REJECT_OR_ACCEPT = 2; + + /** Detect cookie banners but do not handle them. */ + public static final int COOKIE_BANNER_MODE_DETECT_ONLY = 3; + + protected CookieBannerMode() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CookieBannerMode.COOKIE_BANNER_MODE_DISABLED, + CookieBannerMode.COOKIE_BANNER_MODE_REJECT, + CookieBannerMode.COOKIE_BANNER_MODE_REJECT_OR_ACCEPT, + CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY + }) + public @interface CBCookieBannerMode {} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java new file mode 100644 index 0000000000..73238b7eac --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ContentBlockingController.java @@ -0,0 +1,203 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * ContentBlockingController is used to manage and modify the content blocking exception list. This + * list is shared across all sessions. + */ +@AnyThread +public class ContentBlockingController { + private static final String LOGTAG = "GeckoContentBlocking"; + + public static class Event { + // These values must be kept in sync with the corresponding values in + // nsIWebProgressListener.idl. + /** Tracking content has been blocked from loading. */ + public static final int BLOCKED_TRACKING_CONTENT = 0x00001000; + + /** Level 1 tracking content has been loaded. */ + public static final int LOADED_LEVEL_1_TRACKING_CONTENT = 0x00002000; + + /** Level 2 tracking content has been loaded. */ + public static final int LOADED_LEVEL_2_TRACKING_CONTENT = 0x00100000; + + /** Fingerprinting content has been blocked from loading. */ + public static final int BLOCKED_FINGERPRINTING_CONTENT = 0x00000040; + + /** Fingerprinting content has been loaded. */ + public static final int LOADED_FINGERPRINTING_CONTENT = 0x00000400; + + /** Cryptomining content has been blocked from loading. */ + public static final int BLOCKED_CRYPTOMINING_CONTENT = 0x00000800; + + /** Cryptomining content has been loaded. */ + public static final int LOADED_CRYPTOMINING_CONTENT = 0x00200000; + + /** Content which appears on the SafeBrowsing list has been blocked from loading. */ + public static final int BLOCKED_UNSAFE_CONTENT = 0x00004000; + + /** + * Performed a storage access check, which usually means something like a cookie or a storage + * item was loaded/stored on the current tab. Alternatively this could indicate that something + * in the current tab attempted to communicate with its same-origin counterparts in other tabs. + */ + public static final int COOKIES_LOADED = 0x00008000; + + /** + * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a + * third-party tracker when the active cookie policy imposes restrictions on such content. + */ + public static final int COOKIES_LOADED_TRACKER = 0x00040000; + + /** + * Similar to {@link #COOKIES_LOADED}, but only sent if the subject of the action was a + * third-party social tracker when the active cookie policy imposes restrictions on such + * content. + */ + public static final int COOKIES_LOADED_SOCIALTRACKER = 0x00080000; + + /** Rejected for custom site permission. */ + public static final int COOKIES_BLOCKED_BY_PERMISSION = 0x10000000; + + /** Rejected because the resource is a tracker and cookie policy doesn't allow its loading. */ + public static final int COOKIES_BLOCKED_TRACKER = 0x20000000; + + /** + * Rejected because the resource is a tracker from a social origin and cookie policy doesn't + * allow its loading. + */ + public static final int COOKIES_BLOCKED_SOCIALTRACKER = 0x01000000; + + /** Rejected because cookie policy blocks all cookies. */ + public static final int COOKIES_BLOCKED_ALL = 0x40000000; + + /** + * Rejected because the resource is a third-party and cookie policy forces third-party resources + * to be partitioned. + */ + public static final int COOKIES_PARTITIONED_FOREIGN = 0x80000000; + + /** Rejected because cookie policy blocks 3rd party cookies. */ + public static final int COOKIES_BLOCKED_FOREIGN = 0x00000080; + + /** SocialTracking content has been blocked from loading. */ + public static final int BLOCKED_SOCIALTRACKING_CONTENT = 0x00010000; + + /** SocialTracking content has been loaded. */ + public static final int LOADED_SOCIALTRACKING_CONTENT = 0x00020000; + + /** + * Indicates that content that would have been blocked has instead been replaced with a shim. + */ + public static final int REPLACED_TRACKING_CONTENT = 0x00000010; + + /** Indicates that content that would have been blocked has instead been allowed by a shim. */ + public static final int ALLOWED_TRACKING_CONTENT = 0x00000020; + + protected Event() {} + } + + /** An entry in the content blocking log for a site. */ + @AnyThread + public static class LogEntry { + /** Data about why a given entry was blocked. */ + public static class BlockingData { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + Event.BLOCKED_TRACKING_CONTENT, Event.LOADED_LEVEL_1_TRACKING_CONTENT, + Event.LOADED_LEVEL_2_TRACKING_CONTENT, Event.BLOCKED_FINGERPRINTING_CONTENT, + Event.LOADED_FINGERPRINTING_CONTENT, Event.BLOCKED_CRYPTOMINING_CONTENT, + Event.LOADED_CRYPTOMINING_CONTENT, Event.BLOCKED_UNSAFE_CONTENT, + Event.COOKIES_LOADED, Event.COOKIES_LOADED_TRACKER, + Event.COOKIES_LOADED_SOCIALTRACKER, Event.COOKIES_BLOCKED_BY_PERMISSION, + Event.COOKIES_BLOCKED_TRACKER, Event.COOKIES_BLOCKED_SOCIALTRACKER, + Event.COOKIES_BLOCKED_ALL, Event.COOKIES_PARTITIONED_FOREIGN, + Event.COOKIES_BLOCKED_FOREIGN, Event.BLOCKED_SOCIALTRACKING_CONTENT, + Event.LOADED_SOCIALTRACKING_CONTENT, Event.REPLACED_TRACKING_CONTENT + }) + public @interface LogEvent {} + + /** A category the entry falls under. */ + public final @LogEvent int category; + + /** Indicates whether or not blocking occured for this category, where applicable. */ + public final boolean blocked; + + /** The count of consecutive repeated appearances. */ + public final int count; + + /* package */ BlockingData(final @NonNull GeckoBundle bundle) { + category = bundle.getInt("category"); + blocked = bundle.getBoolean("blocked"); + count = bundle.getInt("count"); + } + + protected BlockingData() { + category = Event.BLOCKED_TRACKING_CONTENT; + blocked = false; + count = 0; + } + } + + /** The origin of this log entry. */ + public final @NonNull String origin; + + /** The blocking data for this origin, sorted chronologically. */ + public final @NonNull List<BlockingData> blockingData; + + /* package */ LogEntry(final @NonNull GeckoBundle bundle) { + origin = bundle.getString("origin"); + final GeckoBundle[] data = bundle.getBundleArray("blockData"); + final ArrayList<BlockingData> dataArray = new ArrayList<BlockingData>(data.length); + for (final GeckoBundle b : data) { + dataArray.add(new BlockingData(b)); + } + blockingData = Collections.unmodifiableList(dataArray); + } + + protected LogEntry() { + origin = null; + blockingData = null; + } + } + + private List<LogEntry> logFromBundle(final GeckoBundle value) { + final GeckoBundle[] bundles = value.getBundleArray("log"); + final ArrayList<LogEntry> logArray = new ArrayList<>(bundles.length); + for (final GeckoBundle b : bundles) { + logArray.add(new LogEntry(b)); + } + return Collections.unmodifiableList(logArray); + } + + /** + * Get a log of all content blocking information for the site currently loaded by the supplied + * {@link GeckoSession}. + * + * @param session A {@link GeckoSession} for which you want the content blocking log. + * @return A {@link GeckoResult} that resolves to the list of content blocking log entries. + */ + @UiThread + public @NonNull GeckoResult<List<LogEntry>> getLog(final @NonNull GeckoSession session) { + return session + .getEventDispatcher() + .queryBundle("ContentBlocking:RequestLog") + .map(this::logFromBundle); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java new file mode 100644 index 0000000000..691686e230 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/CrashReporter.java @@ -0,0 +1,385 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLDecoder; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.zip.GZIPOutputStream; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.util.ProxySelector; + +/** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> crash + * report server. + */ +public class CrashReporter { + private static final String LOGTAG = "GeckoCrashReporter"; + private static final String MINI_DUMP_PATH_KEY = "upload_file_minidump"; + private static final String PAGE_URL_KEY = "URL"; + private static final String MINIDUMP_SHA256_HASH_KEY = "MinidumpSha256Hash"; + private static final String NOTES_KEY = "Notes"; + private static final String SERVER_URL_KEY = "ServerURL"; + private static final String STACK_TRACES_KEY = "StackTraces"; + private static final String PRODUCT_NAME_KEY = "ProductName"; + private static final String PRODUCT_ID_KEY = "ProductID"; + private static final String PRODUCT_ID = "{eeb82917-e434-4870-8148-5c03d4caa81b}"; + private static final List<String> IGNORE_KEYS = + Arrays.asList(PAGE_URL_KEY, SERVER_URL_KEY, STACK_TRACES_KEY); + + /** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would + * like to get your app added to the whitelist. + * + * @param context The current Context + * @param intent The Intent sent to the {@link GeckoRuntime} crash handler + * @param appName A human-readable app name. + * @throws IOException This can be thrown if there was a networking error while sending the + * report. + * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was + * invalid. + * @return A GeckoResult containing the crash ID as a String. + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + * @see GeckoRuntime#ACTION_CRASHED + */ + @AnyThread + public static @NonNull GeckoResult<String> sendCrashReport( + @NonNull final Context context, @NonNull final Intent intent, @NonNull final String appName) + throws IOException, URISyntaxException { + return sendCrashReport(context, intent.getExtras(), appName); + } + + /** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would + * like to get your app added to the whitelist. + * + * @param context The current Context + * @param intentExtras The Bundle of extras attached to the Intent received by a crash handler. + * @param appName A human-readable app name. + * @throws IOException This can be thrown if there was a networking error while sending the + * report. + * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was + * invalid. + * @return A GeckoResult containing the crash ID as a String. + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + * @see GeckoRuntime#ACTION_CRASHED + */ + @AnyThread + public static @NonNull GeckoResult<String> sendCrashReport( + @NonNull final Context context, + @NonNull final Bundle intentExtras, + @NonNull final String appName) + throws IOException, URISyntaxException { + final File dumpFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_MINIDUMP_PATH)); + final File extrasFile = new File(intentExtras.getString(GeckoRuntime.EXTRA_EXTRAS_PATH)); + + return sendCrashReport(context, dumpFile, extrasFile, appName); + } + + /** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. <br> + * The {@code appName} needs to be whitelisted for the server to accept the crash. <a + * href="https://bugzilla.mozilla.org/enter_bug.cgi?product=Socorro">File a bug</a> if you would + * like to get your app added to the whitelist. + * + * @param context The current {@link Context} + * @param minidumpFile A {@link File} referring to the minidump. + * @param extrasFile A {@link File} referring to the extras file. + * @param appName A human-readable app name. + * @throws IOException This can be thrown if there was a networking error while sending the + * report. + * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was + * invalid. + * @return A GeckoResult containing the crash ID as a String. + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + * @see GeckoRuntime#ACTION_CRASHED + */ + @AnyThread + public static @NonNull GeckoResult<String> sendCrashReport( + @NonNull final Context context, + @NonNull final File minidumpFile, + @NonNull final File extrasFile, + @NonNull final String appName) + throws IOException, URISyntaxException { + final JSONObject annotations = getCrashAnnotations(context, minidumpFile, extrasFile, appName); + + final String url = annotations.optString(SERVER_URL_KEY, null); + if (url == null) { + return GeckoResult.fromException(new Exception("No server url present")); + } + + for (final String key : IGNORE_KEYS) { + annotations.remove(key); + } + + return sendCrashReport(url, minidumpFile, annotations); + } + + /** + * Sends a crash report to the Mozilla <a href="https://wiki.mozilla.org/Socorro">Socorro</a> + * crash report server. + * + * @param serverURL The URL used to submit the crash report. + * @param minidumpFile A {@link File} referring to the minidump. + * @param extras A {@link JSONObject} holding the parsed JSON from the extra file. + * @throws IOException This can be thrown if there was a networking error while sending the + * report. + * @throws URISyntaxException This can be thrown if the crash server URI from the extra data was + * invalid. + * @return A GeckoResult containing the crash ID as a String. + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + * @see GeckoRuntime#ACTION_CRASHED + */ + @AnyThread + public static @NonNull GeckoResult<String> sendCrashReport( + @NonNull final String serverURL, + @NonNull final File minidumpFile, + @NonNull final JSONObject extras) + throws IOException, URISyntaxException { + Log.d(LOGTAG, "Sending crash report: " + minidumpFile.getPath()); + + HttpURLConnection conn = null; + try { + final URL url = new URL(URLDecoder.decode(serverURL, "UTF-8")); + final URI uri = + new URI( + url.getProtocol(), + url.getUserInfo(), + url.getHost(), + url.getPort(), + url.getPath(), + url.getQuery(), + url.getRef()); + conn = (HttpURLConnection) ProxySelector.openConnectionWithProxy(uri); + conn.setRequestMethod("POST"); + final String boundary = generateBoundary(); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + conn.setRequestProperty("Content-Encoding", "gzip"); + + final OutputStream os = new GZIPOutputStream(conn.getOutputStream()); + sendAnnotations(os, boundary, extras); + sendFile(os, boundary, MINI_DUMP_PATH_KEY, minidumpFile); + os.write(("\r\n--" + boundary + "--\r\n").getBytes()); + os.flush(); + os.close(); + + BufferedReader br = null; + try { + br = new BufferedReader(new InputStreamReader(conn.getInputStream())); + final HashMap<String, String> responseMap = readStringsFromReader(br); + + if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) { + final String crashid = responseMap.get("CrashID"); + if (crashid != null) { + Log.i(LOGTAG, "Successfully sent crash report: " + crashid); + return GeckoResult.fromValue(crashid); + } else { + Log.i(LOGTAG, "Server rejected crash report"); + } + } else { + Log.w( + LOGTAG, "Received failure HTTP response code from server: " + conn.getResponseCode()); + } + } catch (final Exception e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } finally { + try { + if (br != null) { + br.close(); + } + } catch (final IOException e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } + } + } catch (final Exception e) { + return GeckoResult.fromException(new Exception("Failed to submit crash report", e)); + } finally { + if (conn != null) { + conn.disconnect(); + } + } + return GeckoResult.fromException(new Exception("Failed to submit crash report")); + } + + private static String computeMinidumpHash(@NonNull final File minidump) throws IOException { + MessageDigest md = null; + final FileInputStream stream = new FileInputStream(minidump); + try { + md = MessageDigest.getInstance("SHA-256"); + + final byte[] buffer = new byte[4096]; + int readBytes; + + while ((readBytes = stream.read(buffer)) != -1) { + md.update(buffer, 0, readBytes); + } + } catch (final NoSuchAlgorithmException e) { + throw new IOException(e); + } finally { + stream.close(); + } + + final byte[] digest = md.digest(); + final StringBuilder hash = new StringBuilder(64); + + for (int i = 0; i < digest.length; i++) { + hash.append(Integer.toHexString((digest[i] & 0xf0) >> 4)); + hash.append(Integer.toHexString(digest[i] & 0x0f)); + } + + return hash.toString(); + } + + private static HashMap<String, String> readStringsFromReader(final BufferedReader reader) + throws IOException { + String line; + final HashMap<String, String> map = new HashMap<>(); + while ((line = reader.readLine()) != null) { + int equalsPos = -1; + if ((equalsPos = line.indexOf('=')) != -1) { + final String key = line.substring(0, equalsPos); + final String val = unescape(line.substring(equalsPos + 1)); + map.put(key, val); + } + } + return map; + } + + private static JSONObject readExtraFile(final String filePath) throws IOException, JSONException { + final byte[] buffer = new byte[4096]; + final FileInputStream inputStream = new FileInputStream(filePath); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + int bytesRead = 0; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + outputStream.write(buffer, 0, bytesRead); + } + + final String contents = new String(outputStream.toByteArray(), "UTF-8"); + return new JSONObject(contents); + } + + private static JSONObject getCrashAnnotations( + @NonNull final Context context, + @NonNull final File minidump, + @NonNull final File extra, + @NonNull final String appName) + throws IOException { + try { + final JSONObject annotations = readExtraFile(extra.getPath()); + + // Compute the minidump hash and generate the stack traces + try { + final String hash = computeMinidumpHash(minidump); + annotations.put(MINIDUMP_SHA256_HASH_KEY, hash); + } catch (final Exception e) { + Log.e(LOGTAG, "exception while computing the minidump hash: ", e); + } + + annotations.put(PRODUCT_NAME_KEY, appName); + annotations.put(PRODUCT_ID_KEY, PRODUCT_ID); + annotations.put("Android_Manufacturer", Build.MANUFACTURER); + annotations.put("Android_Model", Build.MODEL); + annotations.put("Android_Board", Build.BOARD); + annotations.put("Android_Brand", Build.BRAND); + annotations.put("Android_Device", Build.DEVICE); + annotations.put("Android_Display", Build.DISPLAY); + annotations.put("Android_Fingerprint", Build.FINGERPRINT); + annotations.put("Android_CPU_ABI", Build.CPU_ABI); + annotations.put("Android_PackageName", context.getPackageName()); + try { + annotations.put("Android_CPU_ABI2", Build.CPU_ABI2); + annotations.put("Android_Hardware", Build.HARDWARE); + } catch (final Exception ex) { + Log.e(LOGTAG, "Exception while sending SDK version 8 keys", ex); + } + annotations.put( + "Android_Version", Build.VERSION.SDK_INT + " (" + Build.VERSION.CODENAME + ")"); + + return annotations; + } catch (final JSONException e) { + throw new IOException(e); + } + } + + private static String generateBoundary() { + // Generate some random numbers to fill out the boundary + final int r0 = (int) (Integer.MAX_VALUE * Math.random()); + final int r1 = (int) (Integer.MAX_VALUE * Math.random()); + return String.format("---------------------------%08X%08X", r0, r1); + } + + private static void sendAnnotations( + final OutputStream os, final String boundary, final JSONObject extras) throws IOException { + os.write( + ("--" + + boundary + + "\r\n" + + "Content-Disposition: form-data; name=\"extra\"; " + + "filename=\"extra.json\"\r\n" + + "Content-Type: application/json\r\n" + + "\r\n") + .getBytes()); + os.write(extras.toString().getBytes("UTF-8")); + os.write('\n'); + } + + private static void sendFile( + final OutputStream os, final String boundary, final String name, final File file) + throws IOException { + os.write( + ("--" + + boundary + + "\r\n" + + "Content-Disposition: form-data; name=\"" + + name + + "\"; " + + "filename=\"" + + file.getName() + + "\"\r\n" + + "Content-Type: application/octet-stream\r\n" + + "\r\n") + .getBytes()); + final FileChannel fc = new FileInputStream(file).getChannel(); + fc.transferTo(0, fc.size(), Channels.newChannel(os)); + fc.close(); + } + + private static String unescape(final String string) { + return string.replaceAll("\\\\\\\\", "\\").replaceAll("\\\\n", "\n").replaceAll("\\\\t", "\t"); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java new file mode 100644 index 0000000000..fe6b723983 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/DeprecationSchedule.java @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import static java.lang.annotation.ElementType.CONSTRUCTOR; +import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.LOCAL_VARIABLE; +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.PACKAGE; +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** Additional metadata about a deprecation notice. */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(value = {CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE}) +public @interface DeprecationSchedule { + /** + * @return Major version when we expect to remove the deprecated member attached to this + * annotation. + */ + int version(); + + /** + * @return Identifier for a deprecation notice. All notices with the same identifier will be + * removed at the same time. + */ + String id(); +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java new file mode 100644 index 0000000000..daf7913498 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoDisplay.java @@ -0,0 +1,483 @@ +/* -*- 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.geckoview; + +import android.graphics.Bitmap; +import android.graphics.Rect; +import android.view.Surface; +import android.view.SurfaceControl; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * Applications use a GeckoDisplay instance to provide {@link GeckoSession} with a {@link Surface} + * for displaying content. To ensure drawing only happens on a valid {@link Surface}, {@link + * GeckoSession} will only use the provided {@link Surface} after {@link + * #surfaceChanged(SurfaceInfo)} is called and before {@link #surfaceDestroyed()} returns. + */ +public class GeckoDisplay { + private final GeckoSession mSession; + + protected GeckoDisplay(final GeckoSession session) { + mSession = session; + } + + /** + * Wrapper class containing a Surface and associated information that the compositor should render + * in to. Should be constructed using {@link SurfaceInfo.Builder}. + */ + public static class SurfaceInfo { + /* package */ final @NonNull Surface mSurface; + /* package */ final @Nullable SurfaceControl mSurfaceControl; + /* package */ final int mLeft; + /* package */ final int mTop; + /* package */ final int mWidth; + /* package */ final int mHeight; + + private SurfaceInfo(final @NonNull Builder builder) { + mSurface = builder.mSurface; + mSurfaceControl = builder.mSurfaceControl; + mLeft = builder.mLeft; + mTop = builder.mTop; + mWidth = builder.mWidth; + mHeight = builder.mHeight; + } + + /** Helper class for constructing a {@link SurfaceInfo} object. */ + public static class Builder { + private Surface mSurface; + private SurfaceControl mSurfaceControl; + private int mLeft; + private int mTop; + private int mWidth; + private int mHeight; + + /** + * Creates a new Builder and sets the new Surface. + * + * @param surface The new Surface. + */ + public Builder(final @NonNull Surface surface) { + mSurface = surface; + } + + /** + * Sets the SurfaceControl associated with the new Surface's SurfaceView. + * + * <p>This must be called when rendering in to a {@link android.view.SurfaceView} on SDK level + * 29 or above. On earlier SDK levels, or when rendering in to something other than a + * SurfaceView, this call can be omitted or the value can be null. + * + * @param surfaceControl The SurfaceControl associated with the new Surface's SurfaceView, or + * null. + * @return The builder object + */ + @UiThread + public @NonNull Builder surfaceControl(final @Nullable SurfaceControl surfaceControl) { + mSurfaceControl = surfaceControl; + return this; + } + + /** + * Sets the new compositor origin offset. + * + * @param left The compositor origin offset in the X axis. Can not be negative. + * @param top The compositor origin offset in the Y axis. Can not be negative. + * @return The builder object + */ + @UiThread + public @NonNull Builder offset(final int left, final int top) { + mLeft = left; + mTop = top; + return this; + } + + /** + * Sets the new surface size. + * + * @param width New width of the Surface. Can not be negative. + * @param height New height of the Surface. Can not be negative. + * @return The builder object + */ + @UiThread + public @NonNull Builder size(final int width, final int height) { + mWidth = width; + mHeight = height; + return this; + } + + /** + * Builds the {@link SurfaceInfo} object with the specified properties. + * + * @return The SurfaceInfo object + */ + @UiThread + public @NonNull SurfaceInfo build() { + if ((mLeft < 0) || (mTop < 0)) { + throw new IllegalArgumentException("Left and Top offsets can not be negative."); + } + + return new SurfaceInfo(this); + } + } + } + + /** + * Sets a surface for the compositor render a surface. + * + * <p>Required call. The display's Surface has been created or changed. Must be called on the + * application main thread. GeckoSession may block this call to ensure the Surface is valid while + * resuming drawing. + * + * <p>If rendering in to a {@link android.view.SurfaceView} on SDK level 29 or above, please + * ensure that the SurfaceControl field of the {@link SurfaceInfo} object is set. + * + * @param surfaceInfo Information about the new Surface. + */ + @UiThread + public void surfaceChanged(@NonNull final SurfaceInfo surfaceInfo) { + ThreadUtils.assertOnUiThread(); + + if (mSession.getDisplay() == this) { + mSession.onSurfaceChanged(surfaceInfo); + } + } + + /** + * Removes the current surface registered with the compositor. + * + * <p>Required call. The display's Surface has been destroyed. Must be called on the application + * main thread. GeckoSession may block this call to ensure the Surface is valid while pausing + * drawing. + */ + @UiThread + public void surfaceDestroyed() { + ThreadUtils.assertOnUiThread(); + + if (mSession.getDisplay() == this) { + mSession.onSurfaceDestroyed(); + } + } + + /** + * Update the position of the surface on the screen. + * + * <p>Optional call. The display's coordinates on the screen has changed. Must be called on the + * application main thread. + * + * @param left The X coordinate of the display on the screen, in screen pixels. + * @param top The Y coordinate of the display on the screen, in screen pixels. + */ + @UiThread + public void screenOriginChanged(final int left, final int top) { + ThreadUtils.assertOnUiThread(); + + if (mSession.getDisplay() == this) { + mSession.onScreenOriginChanged(left, top); + } + } + + /** + * Update the safe area insets of the surface on the screen. + * + * @param left left margin of safe area + * @param top top margin of safe area + * @param right right margin of safe area + * @param bottom bottom margin of safe area + */ + @UiThread + public void safeAreaInsetsChanged( + final int top, final int right, final int bottom, final int left) { + ThreadUtils.assertOnUiThread(); + + if (mSession.getDisplay() == this) { + mSession.onSafeAreaInsetsChanged(top, right, bottom, left); + } + } + /** + * Set the maximum height of the dynamic toolbar(s). + * + * <p>If the toolbar is dynamic, this function needs to be called with the maximum possible + * toolbar height so that Gecko can make the ICB static even during the dynamic toolbar height is + * being changed. + * + * @param height The maximum height of the dynamic toolbar(s). + */ + @UiThread + public void setDynamicToolbarMaxHeight(final int height) { + ThreadUtils.assertOnUiThread(); + + if (mSession != null) { + mSession.setDynamicToolbarMaxHeight(height); + } + } + + /** + * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion + * of the display. Tells gecko where to put bottom fixed elements so they are fully visible. + * + * <p>Optional call. The display's visible vertical space has changed. Must be called on the + * application main thread. + * + * @param clippingHeight The height of the bottom clipped space in screen pixels. + */ + @UiThread + public void setVerticalClipping(final int clippingHeight) { + ThreadUtils.assertOnUiThread(); + + if (mSession != null) { + mSession.setFixedBottomOffset(clippingHeight); + } + } + + /** + * Return whether the display should be pinned on the screen. + * + * <p>When pinned, the display should not be moved on the screen due to animation, scrolling, etc. + * A common reason for the display being pinned is when the user is dragging a selection caret + * inside the display; normal user interaction would be disrupted in that case if the display was + * moved on screen. + * + * @return True if display should be pinned on the screen. + */ + @UiThread + public boolean shouldPinOnScreen() { + ThreadUtils.assertOnUiThread(); + return mSession.getDisplay() == this && mSession.shouldPinOnScreen(); + } + + /** + * Request a {@link Bitmap} of the visible portion of the web page currently being rendered. + * + * <p>Returned {@link Bitmap} will have the same dimensions as the {@link Surface} the {@link + * GeckoDisplay} is currently using. + * + * <p>If the {@link GeckoSession#isCompositorReady} is false the {@link GeckoResult} will complete + * with an {@link IllegalStateException}. + * + * <p>This function must be called on the UI thread. + * + * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and + * size information of the currently visible rendered web page. + */ + @UiThread + public @NonNull GeckoResult<Bitmap> capturePixels() { + return screenshot().capture(); + } + + /** Builder to construct screenshot requests. */ + public static final class ScreenshotBuilder { + private static final int NONE = 0; + private static final int SCALE = 1; + private static final int ASPECT = 2; + private static final int FULL = 3; + private static final int RECYCLE = 4; + + private final GeckoSession mSession; + private int mOffsetX; + private int mOffsetY; + private int mSrcWidth; + private int mSrcHeight; + private int mOutWidth; + private int mOutHeight; + private int mAspectPreservingWidth; + private float mScale; + private Bitmap mRecycle; + private int mSizeType; + + /* package */ ScreenshotBuilder(final GeckoSession session) { + this.mSizeType = NONE; + this.mSession = session; + } + + /** + * The screenshot will be of a region instead of the entire screen + * + * @param x Left most pixel of the source region. + * @param y Top most pixel of the source region. + * @param width Width of the source region in screen pixels + * @param height Height of the source region in screen pixels + * @return The builder + */ + @AnyThread + public @NonNull ScreenshotBuilder source( + final int x, final int y, final int width, final int height) { + mOffsetX = x; + mOffsetY = y; + mSrcWidth = width; + mSrcHeight = height; + return this; + } + + /** + * The screenshot will be of a region instead of the entire screen + * + * @param source Region of the screen to capture in screen pixels + * @return The builder + */ + @AnyThread + public @NonNull ScreenshotBuilder source(final @NonNull Rect source) { + mOffsetX = source.left; + mOffsetY = source.top; + mSrcWidth = source.width(); + mSrcHeight = source.height(); + return this; + } + + private void checkAndSetSizeType(final int sizeType) { + if (mSizeType != NONE) { + throw new IllegalStateException("Size has already been set."); + } + mSizeType = sizeType; + } + + /** + * The width of the bitmap to create when taking the screenshot. The height will be calculated + * to match the aspect ratio of the source as closely as possible. The source screenshot will be + * scaled into the resulting Bitmap. + * + * @param width of the result Bitmap in screen pixels. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder aspectPreservingSize(final int width) { + checkAndSetSizeType(ASPECT); + mAspectPreservingWidth = width; + return this; + } + + /** + * The scale of the bitmap relative to the source. The height and width of the output bitmap + * will be within one pixel of this multiple of the source dimensions. The source screenshot + * will be scaled into the resulting Bitmap. + * + * @param scale of the result Bitmap relative to the source. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder scale(final float scale) { + checkAndSetSizeType(SCALE); + mScale = scale; + return this; + } + + /** + * Size of the bitmap to create when taking the screenshot. The source screenshot will be scaled + * into the resulting Bitmap + * + * @param width of the result Bitmap in screen pixels. + * @param height of the result Bitmap in screen pixels. + * @return The builder + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder size(final int width, final int height) { + checkAndSetSizeType(FULL); + mOutWidth = width; + mOutHeight = height; + return this; + } + + /** + * Instead of creating a new Bitmap for the result, the builder will use the passed Bitmap. + * + * @param bitmap The Bitmap to use in the result. + * @return The builder. + * @throws IllegalStateException if the size has already been set in some other way. + */ + @AnyThread + public @NonNull ScreenshotBuilder bitmap(final @Nullable Bitmap bitmap) { + checkAndSetSizeType(RECYCLE); + mRecycle = bitmap; + return this; + } + + /** + * Request a {@link Bitmap} of the requested portion of the web page currently being rendered + * using any parameters specified with the builder. + * + * <p>This function must be called on the UI thread. + * + * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and + * size information of the requested portion of the visible web page. + */ + @UiThread + public @NonNull GeckoResult<Bitmap> capture() { + ThreadUtils.assertOnUiThread(); + if (!mSession.isCompositorReady()) { + throw new IllegalStateException("Compositor must be ready before pixels can be captured"); + } + + final GeckoResult<Bitmap> result = new GeckoResult<>(); + final Bitmap target; + final Rect rect = new Rect(); + + if (mSrcWidth == 0 || mSrcHeight == 0) { + // Source is unset or invalid, use defaults. + mSession.getSurfaceBounds(rect); + mSrcWidth = rect.width(); + mSrcHeight = rect.height(); + } + + switch (mSizeType) { + case NONE: + mOutWidth = mSrcWidth; + mOutHeight = mSrcHeight; + break; + case SCALE: + mSession.getSurfaceBounds(rect); + mOutWidth = (int) (rect.width() * mScale); + mOutHeight = (int) (rect.height() * mScale); + break; + case ASPECT: + mSession.getSurfaceBounds(rect); + mOutWidth = mAspectPreservingWidth; + mOutHeight = (int) (rect.height() * (mAspectPreservingWidth / (double) rect.width())); + break; + case RECYCLE: + mOutWidth = mRecycle.getWidth(); + mOutHeight = mRecycle.getHeight(); + break; + // case FULL does not need to be handled, as width and height are already set. + } + + if (mRecycle == null) { + try { + target = Bitmap.createBitmap(mOutWidth, mOutHeight, Bitmap.Config.ARGB_8888); + } catch (final Throwable e) { + if (e instanceof NullPointerException || e instanceof OutOfMemoryError) { + return GeckoResult.fromException( + new OutOfMemoryError("Not enough memory to allocate for bitmap")); + } + return GeckoResult.fromException(new Throwable("Failed to create bitmap", e)); + } + } else { + target = mRecycle; + } + + mSession.mCompositor.requestScreenPixels( + result, target, mOffsetX, mOffsetY, mSrcWidth, mSrcHeight, mOutWidth, mOutHeight); + + return result; + } + } + + /** + * Creates a new screenshot builder. + * + * @return The new {@link ScreenshotBuilder} + */ + @UiThread + public @NonNull ScreenshotBuilder screenshot() { + return new ScreenshotBuilder(mSession); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java new file mode 100644 index 0000000000..2d24dcbe93 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoEditable.java @@ -0,0 +1,2616 @@ +/* -*- 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.geckoview; + +import android.graphics.RectF; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.os.SystemClock; +import android.text.Editable; +import android.text.InputFilter; +import android.text.InputType; +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.method.KeyListener; +import android.text.method.TextKeyListener; +import android.text.style.CharacterStyle; +import android.util.Log; +import android.view.InputDevice; +import android.view.KeyCharacterMap; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; +import org.mozilla.gecko.GeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableChild; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.ThreadUtils.AssertBehavior; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEContextFlags; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMENotificationType; +import org.mozilla.geckoview.SessionTextInput.EditableListener.IMEState; + +/** + * GeckoEditable implements only some functions of Editable The field mText contains the actual + * underlying SpannableStringBuilder/Editable that contains our text. + */ +/* package */ final class GeckoEditable extends IGeckoEditableParent.Stub + implements InvocationHandler, Editable, SessionTextInput.EditableClient { + + private static final boolean DEBUG = false; + private static final String LOGTAG = "GeckoEditable"; + + // Filters to implement Editable's filtering functionality + private InputFilter[] mFilters; + + /** + * We need a WeakReference here to avoid unnecessary retention of the GeckoSession. Passing + * objects around via JNI seems to confuse the GC into thinking we have a native GC root. + */ + /* package */ final WeakReference<GeckoSession> mSession; + + 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; + + // Parent process child used as a default for key events. + /* package */ IGeckoEditableChild mDefaultChild; // Used by IC thread. + // Parent or content process child that has the focus. + /* package */ IGeckoEditableChild mFocusedChild; // Used by IC thread. + /* package */ IBinder mFocusedToken; // Used by Gecko/binder thread. + /* package */ SessionTextInput.EditableListener mListener; + + /* 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 + + @IMEState + private int mIMEState = // Used by IC thread. + SessionTextInput.EditableListener.IME_STATE_DISABLED; + + private String mIMETypeHint = ""; // Used by IC/UI thread. + private String mIMEModeHint = ""; // Used by IC thread. + private String mIMEActionHint = ""; // Used by IC thread. + private String mIMEAutocapitalize = ""; // Used by IC thread. + @IMEContextFlags private int mIMEFlags; // Used by IC thread. + + private boolean mIgnoreSelectionChange; // Used by Gecko thread + // Combined offsets from the previous batch of onTextChange calls; valid + // between the onTextChange calls and the next onSelectionChange call. + private int mLastTextChangeStart = Integer.MAX_VALUE; // Used by Gecko thread + private int mLastTextChangeOldEnd = -1; // Used by Gecko thread + private int mLastTextChangeNewEnd = -1; // Used by Gecko thread + private boolean mLastTextChangeReplacedSelection; // Used by Gecko thread + + // Prevent showSoftInput and hideSoftInput from being called multiple times in a row, + // including reentrant calls on some devices. Used by UI/IC thread. + /* package */ final AtomicInteger mSoftInputReentrancyGuard = new AtomicInteger(); + + 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_SOLID = 1; + private static final int IME_RANGE_LINE_DOTTED = 2; + private static final int IME_RANGE_LINE_DASHED = 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; + + private void onKeyEvent( + final IGeckoEditableChild child, + final KeyEvent event, + final int action, + final int savedMetaState, + final boolean isSynthesizedImeKey) + throws RemoteException { + // 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 unmodifiedUnicodeChar = event.getUnicodeChar(unmodifiedMetaState); + final int domPrintableKeyValue = + unicodeChar >= ' ' + ? unicodeChar + : unmodifiedMetaState != metaState ? unmodifiedUnicodeChar : 0; + + // If a modifier (e.g. meta key) caused a different character to be entered, we + // drop that modifier from the metastate for the generated keypress event. + final int keyPressMetaState = + (unicodeChar >= ' ' && unicodeChar != unmodifiedUnicodeChar) + ? unmodifiedMetaState + : metaState; + + // For synthesized keys, ignore modifier metastates from the synthesized event, + // because the synthesized modifier metastates don't reflect the actual state of + // the meta keys (bug 1387889). For example, the Latin sharp S (U+00DF) is + // synthesized as Alt+S, but we don't want the Alt metastate because the Alt key + // is not actually pressed in this case. + final int keyUpDownMetaState = + isSynthesizedImeKey ? (unmodifiedMetaState | savedMetaState) : metaState; + + child.onKeyEvent( + action, + event.getKeyCode(), + event.getScanCode(), + keyUpDownMetaState, + keyPressMetaState, + event.getEventTime(), + domPrintableKeyValue, + event.getRepeatCount(), + event.getFlags(), + isSynthesizedImeKey, + event); + } + + /** + * 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) { + // On Gecko or binder thread. + mCurrentText.replace(start, end, newText); + addCurrentChangeLocked(start, end, start + newText.length()); + } + + public synchronized void currentSetSelection(final int start, final int end) { + // On Gecko or binder thread. + Selection.setSelection(mCurrentText, start, end); + mCurrentSelectionChanged = true; + } + + public synchronized void currentSetSpan( + final Object obj, final int start, final int end, final int flags) { + // On Gecko or binder thread. + mCurrentText.setSpan(obj, start, end, flags); + addCurrentChangeLocked(start, end, end); + } + + public synchronized void currentRemoveSpan(final Object obj) { + // On Gecko or binder thread. + 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() { + // On Gecko or binder thread. + 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; + } + + /** + * Check whether we are currently discarding the composition. It means that shadow text has + * composition, but current text has no composition. So syncShadowText will discard composition. + * + * @return true if discarding composition + */ + private boolean isDiscardingComposition() { + if (!isComposing(mShadowText)) { + return false; + } + + return !isComposing(mCurrentText); + } + + public synchronized void syncShadowText(final SessionTextInput.EditableListener 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; + } + + if (isDiscardingComposition()) { + if (listener != null) { + listener.onDiscardComposition(); + } + } + + // 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); + + // Remove existing spans that may no longer be in the new text. + Object[] spans = mShadowText.getSpans(start, shadowEnd, Object.class); + for (final Object span : spans) { + mShadowText.removeSpan(span); + } + + mShadowText.replace(start, shadowEnd, mCurrentText, start, currentEnd); + + // The replace() call may not have copied all affected spans, so we re-copy all the + // spans manually just in case. Expand bounds by 1 so we get all the spans. + spans = + mCurrentText.getSpans( + Math.max(start - 1, 0), + Math.min(currentEnd + 1, mCurrentText.length()), + Object.class); + for (final Object span : spans) { + if (span == Selection.SELECTION_START || span == Selection.SELECTION_END) { + continue; + } + mShadowText.setSpan( + span, + mCurrentText.getSpanStart(span), + mCurrentText.getSpanEnd(span), + mCurrentText.getSpanFlags(span)); + } + + // 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 && !checkEqualText(mShadowText, 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; + } + } + + private static boolean checkEqualText(final Spanned s1, final Spanned s2) { + if (!s1.toString().equals(s2.toString())) { + return false; + } + + final Object[] o1s = s1.getSpans(0, s1.length(), Object.class); + final Object[] o2s = s2.getSpans(0, s2.length(), Object.class); + + if (o1s.length != o2s.length) { + return false; + } + + o1loop: + for (final Object o1 : o1s) { + for (final Object o2 : o2s) { + if (o1 != o2) { + continue; + } + if (s1.getSpanStart(o1) != s2.getSpanStart(o2) + || s1.getSpanEnd(o1) != s2.getSpanEnd(o2) + || s1.getSpanFlags(o1) != s2.getSpanFlags(o2)) { + return false; + } + continue o1loop; + } + // o1 not found in o2s. + return false; + } + return true; + } + + /* 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(final int type) { + mType = type; + } + + static Action newReplaceText(final CharSequence text, final int start, final 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(final Object object, final int start, final int end, final 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(final Object object) { + final Action action = new Action(TYPE_REMOVE_SPAN); + action.mSpanObject = object; + return action; + } + + static Action newSetHandler(final 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) + ")"); + } + + switch (action.mType) { + case Action.TYPE_EVENT: + case Action.TYPE_SET_HANDLER: + break; + + case Action.TYPE_SET_SPAN: + mText.shadowSetSpan( + action.mSpanObject, action.mStart, + action.mEnd, action.mSpanFlags); + break; + + case Action.TYPE_REMOVE_SPAN: + action.mSpanFlags = mText.getShadowText().getSpanFlags(action.mSpanObject); + mText.shadowRemoveSpan(action.mSpanObject); + break; + + case Action.TYPE_REPLACE_TEXT: + mText.shadowReplace(action.mStart, action.mEnd, action.mSequence); + break; + + default: + throw new IllegalStateException("Action not processed"); + } + + // Always perform actions on the shadow text side above, so we still act as a + // valid Editable object, but don't send the actions to Gecko below if we haven't + // been focused or initialized, or we've been destroyed. + if (mFocusedChild == null || mListener == null) { + return; + } + + mActions.offer(action); + + try { + icPerformAction(action); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + // Undo the offer. + mActions.remove(action); + } + } + + private void icPerformAction(final Action action) throws RemoteException { + switch (action.mType) { + case Action.TYPE_EVENT: + case Action.TYPE_SET_HANDLER: + mFocusedChild.onImeSynchronize(); + break; + + case Action.TYPE_SET_SPAN: + { + final boolean needUpdate = + (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0 + && ((action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0 + || action.mSpanObject == Selection.SELECTION_START + || action.mSpanObject == Selection.SELECTION_END); + + action.mSequence = TextUtils.substring(mText.getShadowText(), action.mStart, action.mEnd); + + mNeedUpdateComposition |= needUpdate; + if (needUpdate) { + icMaybeSendComposition( + mText.getShadowText(), + SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT); + } + + mFocusedChild.onImeSynchronize(); + break; + } + case Action.TYPE_REMOVE_SPAN: + { + final boolean needUpdate = + (action.mSpanFlags & Spanned.SPAN_INTERMEDIATE) == 0 + && (action.mSpanFlags & Spanned.SPAN_COMPOSING) != 0; + + mNeedUpdateComposition |= needUpdate; + if (needUpdate) { + icMaybeSendComposition( + mText.getShadowText(), + SEND_COMPOSITION_NOTIFY_GECKO | SEND_COMPOSITION_KEEP_CURRENT); + } + + mFocusedChild.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, SEND_COMPOSITION_USE_ENTIRE_TEXT)) { + mFocusedChild.onImeReplaceText(action.mStart, action.mEnd, action.mSequence.toString()); + break; + } + + // Since we don't have a composition, we can try sending key events. + sendCharKeyEvents(action); + + // onImeReplaceText will set the selection range. But we don't + // know whether event state manager is processing text and + // selection. So current shadow may not be synchronized with + // Gecko's text and selection. So we have to avoid unnecessary + // selection update. + final int selStartOnShadow = Selection.getSelectionStart(mText.getShadowText()); + final int selEndOnShadow = Selection.getSelectionEnd(mText.getShadowText()); + int actionStart = action.mStart; + int actionEnd = action.mEnd; + // If action range is collapsed and selection of shadow text is + // collapsed, we may try to dispatch keypress on current caret + // position. Action range is previous range before dispatching + // keypress, and shadow range is new range after dispatching + // it. + if (action.mStart == action.mEnd + && selStartOnShadow == selEndOnShadow + && action.mStart == selStartOnShadow + action.mSequence.toString().length()) { + // Replacing range is same value as current shadow's selection. + // So it is unnecessary to update the selection on Gecko. + actionStart = -1; + actionEnd = -1; + } + mFocusedChild.onImeReplaceText(actionStart, actionEnd, action.mSequence.toString()); + break; + + default: + throw new IllegalStateException("Action not processed"); + } + } + + private KeyEvent[] synthesizeKeyEvents(final CharSequence cs) { + try { + if (mKeyMap == null) { + mKeyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD); + } + } catch (final 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; + } + final KeyEvent[] keyEvents = mKeyMap.getEvents(cs.toString().toCharArray()); + if (keyEvents == null || keyEvents.length == 0) { + return null; + } + return keyEvents; + } + + private void sendCharKeyEvents(final Action action) throws RemoteException { + 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; + } + final KeyEvent[] keyEvents = synthesizeKeyEvents(action.mSequence); + if (keyEvents == null) { + return; + } + for (final 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( + mFocusedChild, + event, + event.getAction(), + /* metaState */ 0, /* isSynthesizedImeKey */ + true); + } + } + + public GeckoEditable(@NonNull final GeckoSession session) { + if (DEBUG) { + // Called by SessionTextInput. + ThreadUtils.assertOnUiThread(); + } + + mSession = new WeakReference<>(session); + 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(); + } + + @Override // IGeckoEditableParent + public void setDefaultChild(final IGeckoEditableChild child) { + if (DEBUG) { + // On Gecko or binder thread. + Log.d(LOGTAG, "setDefaultEditableChild " + child); + } + mDefaultChild = child; + } + + public void setListener(final SessionTextInput.EditableListener newListener) { + if (DEBUG) { + // Called by SessionTextInput. + ThreadUtils.assertOnUiThread(); + Log.d(LOGTAG, "setListener " + newListener); + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "onViewChange (set listener)"); + } + + mListener = newListener; + } + }); + } + + private boolean onIcThread() { + return mIcRunHandler.getLooper() == Looper.myLooper(); + } + + private void assertOnIcThread() { + ThreadUtils.assertOnThread(mIcRunHandler.getLooper().getThread(), AssertBehavior.THROW); + } + + private Object getField(final Object obj, final String field, final Object def) { + try { + return obj.getClass().getField(field).get(obj); + } catch (final Exception e) { + return def; + } + } + + // Flags for icMaybeSendComposition + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SEND_COMPOSITION_USE_ENTIRE_TEXT, + SEND_COMPOSITION_NOTIFY_GECKO, + SEND_COMPOSITION_KEEP_CURRENT + }) + public @interface CompositionFlags {} + + // If text has composing spans, treat the entire text as a Gecko composition, + // instead of just the spanned part. + private static final int SEND_COMPOSITION_USE_ENTIRE_TEXT = 1 << 0; + // Notify Gecko of the new composition ranges; + // otherwise, the caller is responsible for notifying Gecko. + private static final int SEND_COMPOSITION_NOTIFY_GECKO = 1 << 1; + // Keep the current composition when updating; + // composition is not updated if there is no current composition. + private static final int SEND_COMPOSITION_KEEP_CURRENT = 1 << 2; + + /** + * Send composition ranges to Gecko if the text has composing spans. + * + * @param sequence Text with possible composing spans + * @param flags Bitmask of SEND_COMPOSITION_* flags for updating composition. + * @return Whether there was a composition + */ + private boolean icMaybeSendComposition( + final CharSequence sequence, @CompositionFlags final int flags) throws RemoteException { + final boolean useEntireText = (flags & SEND_COMPOSITION_USE_ENTIRE_TEXT) != 0; + final boolean notifyGecko = (flags & SEND_COMPOSITION_NOTIFY_GECKO) != 0; + final boolean keepCurrent = (flags & SEND_COMPOSITION_KEEP_CURRENT) != 0; + final int updateFlags = keepCurrent ? GeckoEditableChild.FLAG_KEEP_CURRENT_COMPOSITION : 0; + + if (!keepCurrent) { + // If keepCurrent is true, the composition may not actually be updated; + // so we may still need to update the composition in the future. + 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 (final 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) { + if (selStart < composingStart || selEnd > composingEnd) { + // GBoard will set caret position that is out of composing + // range. Unfortunately, Gecko doesn't support this caret + // position. So we shouldn't set composing range data now. + // But this is temporary composing range, then GBoard will + // set valid range soon. + if (DEBUG) { + final StringBuilder sb = + new StringBuilder("icSendComposition(): invalid caret position. "); + sb.append("composing = ") + .append(composingStart) + .append("-") + .append(composingEnd) + .append(", selection = ") + .append(selStart) + .append("-") + .append(selEnd); + Log.d(LOGTAG, sb.toString()); + } + } else { + icSendComposition(text, selStart, selEnd, composingStart, composingEnd); + if (notifyGecko) { + mFocusedChild.onImeUpdateComposition(composingStart, composingEnd, updateFlags); + } + return true; + } + } + } + + if (notifyGecko) { + // Set the selection by using a composition without ranges. + final Spanned currentText = mText.getCurrentText(); + if (Selection.getSelectionStart(currentText) != selStart + || Selection.getSelectionEnd(currentText) != selEnd) { + // Gecko's selection is different of requested selection, so + // we have to set selection of Gecko side. + // If selection is same, it is unnecessary to update it. + // This may be race with Gecko's updating selection via + // JavaScript or keyboard event. But we don't know whether + // Gecko is during updating selection. + mFocusedChild.onImeUpdateComposition(selStart, selEnd, updateFlags); + } + } + + 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) + throws RemoteException { + if (DEBUG) { + assertOnIcThread(); + final StringBuilder sb = new StringBuilder("icSendComposition("); + sb.append("\"") + .append(text) + .append("\"") + .append(", range = ") + .append(composingStart) + .append("-") + .append(composingEnd) + .append(", selection = ") + .append(selStart) + .append("-") + .append(selEnd) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (selEnd >= composingStart && selEnd <= composingEnd) { + mFocusedChild.onImeAddCompositionRange( + selEnd - composingStart, + selEnd - composingStart, + IME_RANGE_CARETPOSITION, + 0, + 0, + false, + 0, + 0, + 0); + } + + int rangeStart = composingStart; + final TextPaint tp = new TextPaint(); + final 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 { + final int rangeType; + int rangeStyles = 0; + int 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; + } + final 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 (final 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; + } + } + mFocusedChild.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); + } + + @Override // SessionTextInput.EditableClient + public void sendKeyEvent( + final @Nullable View view, final int action, final @NonNull KeyEvent event) { + final Editable editable = mProxy; + final KeyListener keyListener = TextKeyListener.getInstance(); + final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event); + + // We only let TextKeyListener do UI things on the UI thread. + final View v = ThreadUtils.isOnUiThread() ? view : null; + final int keyCode = translatedEvent.getKeyCode(); + final boolean handled; + + if (shouldSkipKeyListener(keyCode, translatedEvent)) { + handled = false; + } else if (action == KeyEvent.ACTION_DOWN) { + setSuppressKeyUp(true); + handled = keyListener.onKeyDown(v, editable, keyCode, translatedEvent); + } else if (action == KeyEvent.ACTION_UP) { + handled = keyListener.onKeyUp(v, editable, keyCode, translatedEvent); + } else { + handled = keyListener.onKeyOther(v, editable, translatedEvent); + } + + if (!handled) { + sendKeyEvent(translatedEvent, 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); + } + setSuppressKeyUp(false); + } + } + + private void sendKeyEvent(final @NonNull KeyEvent event, final int action, final 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. + */ + try { + if (mFocusedChild == null) { + if (mDefaultChild == null) { + Log.w(LOGTAG, "Discarding key event"); + return; + } + // Not focused; send simple key event to chrome window. + onKeyEvent(mDefaultChild, event, action, metaState, /* isSynthesizedImeKey */ false); + return; + } + + // Most IMEs handle arrow key, then set caret position. But GBoard + // doesn't handle it. GBoard will dispatch KeyEvent for arrow left/right + // even if having IME composition. + // Since Gecko doesn't dispatch keypress during IME composition due to + // DOM UI events spec, we have to emulate arrow key's behaviour. + boolean commitCompositionBeforeKeyEvent = action == KeyEvent.ACTION_DOWN; + if (isComposing(mText.getShadowText()) + && action == KeyEvent.ACTION_DOWN + && event.hasNoModifiers()) { + final int selStart = Selection.getSelectionStart(mText.getShadowText()); + final int selEnd = Selection.getSelectionEnd(mText.getShadowText()); + if (selStart == selEnd) { + // If dispatching arrow left/right key into composition, + // we update IME caret. + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_DPAD_LEFT: + if (getComposingStart(mText.getShadowText()) < selStart) { + Selection.setSelection(getEditable(), selStart - 1, selStart - 1); + mNeedUpdateComposition = true; + commitCompositionBeforeKeyEvent = false; + } else if (selStart == 0) { + // Keep current composition + commitCompositionBeforeKeyEvent = false; + } + break; + case KeyEvent.KEYCODE_DPAD_RIGHT: + if (getComposingEnd(mText.getShadowText()) > selEnd) { + Selection.setSelection(getEditable(), selStart + 1, selStart + 1); + mNeedUpdateComposition = true; + commitCompositionBeforeKeyEvent = false; + } else if (selEnd == mText.getShadowText().length()) { + // Keep current composition + commitCompositionBeforeKeyEvent = false; + } + break; + } + } + } + + // Focused; key event may go to chrome window or to content window. + if (mNeedUpdateComposition) { + icMaybeSendComposition(mText.getShadowText(), SEND_COMPOSITION_NOTIFY_GECKO); + } + + if (commitCompositionBeforeKeyEvent) { + mFocusedChild.onImeRequestCommit(); + } + onKeyEvent(mFocusedChild, event, action, metaState, /* isSynthesizedImeKey */ false); + icOfferAction(new Action(Action.TYPE_EVENT)); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + private boolean shouldSkipKeyListener(final int keyCode, final @NonNull KeyEvent event) { + if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + 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 static KeyEvent translateSonyXperiaGamepadKeys(final int keyCode, final 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 + final boolean areKeysSwapped = areSonyXperiaGamepadKeysSwapped(); + + int translatedKeyCode = keyCode; + // 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: + translatedKeyCode = + (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_A : KeyEvent.KEYCODE_BUTTON_B); + break; + + case KeyEvent.KEYCODE_DPAD_CENTER: + translatedKeyCode = + (areKeysSwapped ? KeyEvent.KEYCODE_BUTTON_B : KeyEvent.KEYCODE_BUTTON_A); + break; + + default: + return event; + } + + return new KeyEvent(event.getAction(), translatedKeyCode); + } + + private static final int SONY_XPERIA_GAMEPAD_DEVICE_ID = 196611; + + private static boolean isSonyXperiaGamepadKeyEvent(final 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; + final int[] deviceIds = InputDevice.getDeviceIds(); + + for (int i = 0; deviceIds != null && i < deviceIds.length; i++) { + final KeyCharacterMap keyCharacterMap = KeyCharacterMap.load(deviceIds[i]); + if (keyCharacterMap != null + && DEFAULT_O_BUTTON_LABEL + == keyCharacterMap.getDisplayLabel(KeyEvent.KEYCODE_DPAD_CENTER)) { + swapped = true; + break; + } + } + return swapped; + } + + private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) { + if (isSonyXperiaGamepadKeyEvent(event)) { + return translateSonyXperiaGamepadKeys(keyCode, event); + } + return event; + } + + @Override // SessionTextInput.EditableClient + 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 // SessionTextInput.EditableClient + public void setBatchMode(final 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 && mFocusedChild != null) { + // We may not commit composition on Gecko even if Java side has + // no composition. So we have to sync composition state with Gecko + // when batch edit is done. + // + // i.e. Although finishComposingText removes composing span, we + // don't commit current composition yet. + final Editable editable = getEditable(); + if (editable != null && !isComposing(editable)) { + try { + mFocusedChild.onImeRequestCommit(); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + // Committing composition doesn't change text, so we can sync shadow text. + } + + 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 setSuppressKeyUp(final boolean suppress) { + if (DEBUG) { + assertOnIcThread(); + } + // Suppress key up event generated as a result of + // translating characters to key events + mSuppressKeyUp = suppress; + } + + @Override // SessionTextInput.EditableClient + 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 // SessionTextInput.EditableClient + public void postToInputConnection(final Runnable runnable) { + mIcPostHandler.post(runnable); + } + + @Override // SessionTextInput.EditableClient + public void requestCursorUpdates(@CursorMonitorMode final int requestMode) { + try { + if (mFocusedChild != null) { + mFocusedChild.onImeRequestCursorUpdates(requestMode); + } + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call failed", e); + } + } + + @Override // SessionTextInput.EditableClient + public void insertImage(final @NonNull byte[] data, final @NonNull String mimeType) { + if (mFocusedChild == null) { + return; + } + + try { + mFocusedChild.onImeInsertImage(data, mimeType); + } catch (final RemoteException e) { + Log.e(LOGTAG, "Remote call to insert image failed", e); + } + } + + private void geckoSetIcHandler(final Handler newHandler) { + // On Gecko or binder thread. + mIcPostHandler.post( + 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) { + // On Gecko or binder thread. + if (action == null) { + Log.w(LOGTAG, "Mismatched reply"); + return; + } + if (DEBUG) { + Log.d(LOGTAG, "reply: Action(" + getConstantName(Action.class, "TYPE_", action.mType) + ")"); + } + switch (action.mType) { + case Action.TYPE_REPLACE_TEXT: + { + final Spanned currentText = mText.getCurrentText(); + final int actionNewEnd = action.mStart + action.mSequence.length(); + if (mLastTextChangeStart > mLastTextChangeNewEnd + || mLastTextChangeNewEnd > currentText.length() + || action.mStart < mLastTextChangeStart + || actionNewEnd > mLastTextChangeNewEnd) { + // Replace-text action doesn't match our text change. + break; + } + + int indexInText = + TextUtils.indexOf( + currentText, action.mSequence, action.mStart, mLastTextChangeNewEnd); + if (indexInText < 0 && action.mStart != mLastTextChangeStart) { + final String changedText = + TextUtils.substring(currentText, mLastTextChangeStart, actionNewEnd); + indexInText = changedText.lastIndexOf(action.mSequence.toString()); + if (indexInText >= 0) { + indexInText += mLastTextChangeStart; + } + } + if (indexInText < 0) { + // Replace-text action doesn't match our current text. + break; + } + + final int selStart = Selection.getSelectionStart(currentText); + final int selEnd = Selection.getSelectionEnd(currentText); + + // Replace-text action matches our current text; copy the new spans to the + // current text. + mText.currentReplace( + indexInText, indexInText + action.mSequence.length(), action.mSequence); + // Make sure selection is preserved. + mText.currentSetSelection(selStart, selEnd); + + // The text change is caused by the replace-text event. If the text change + // replaced the previous selection, we need to rely on Gecko for an updated + // selection, so don't ignore selection change. However, if the text change + // did not replace the previous selection, we can ignore the Gecko selection + // in favor of the Java selection. + mIgnoreSelectionChange = !mLastTextChangeReplacedSelection; + break; + } + + 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; + } + if ((action.mSpanObject == Selection.SELECTION_START + || action.mSpanObject == Selection.SELECTION_END) + && (action.mStart < mLastTextChangeStart && action.mEnd < mLastTextChangeStart + || action.mStart > mLastTextChangeOldEnd && action.mEnd > mLastTextChangeOldEnd)) { + // Use the Java selection if, between text-change notification and replace-text + // processing, we specifically set the selection to outside the replaced range. + mLastTextChangeReplacedSelection = false; + } + 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 synchronized boolean binderCheckToken(final IBinder token, final boolean allowNull) { + // Verify that we're getting an IME notification from the currently focused child. + if (mFocusedToken == token || (mFocusedToken == null && allowNull)) { + return true; + } + Log.w(LOGTAG, "Invalid token"); + return false; + } + + @Override // IGeckoEditableParent + public void notifyIME(final IGeckoEditableChild child, @IMENotificationType final int type) { + // On Gecko or binder thread. + if (DEBUG) { + // NOTIFY_IME_REPLY_EVENT is logged separately, inside geckoActionReply() + if (type != SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + Log.d( + LOGTAG, + "notifyIME(" + + getConstantName(SessionTextInput.EditableListener.class, "NOTIFY_IME_", type) + + ")"); + } + } + + final IBinder token = child.asBinder(); + if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN) { + synchronized (this) { + if (mFocusedToken != null && mFocusedToken != token && mFocusedToken.pingBinder()) { + // Focused child already exists and is alive. + Log.w(LOGTAG, "Already focused"); + return; + } + mFocusedToken = token; + return; + } + } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB) { + // Always from parent process. + ThreadUtils.assertOnGeckoThread(); + } else if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (type == SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR) { + synchronized (this) { + onTextChange(token, "", 0, Integer.MAX_VALUE, false); + mActions.clear(); + mFocusedToken = null; + } + } else if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + geckoActionReply(mActions.poll()); + if (!mActions.isEmpty()) { + // Only post to IC thread below when the queue is empty. + return; + } + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icNotifyIME(child, type); + } + }); + } + + /* package */ void icNotifyIME( + final IGeckoEditableChild child, @IMENotificationType final int type) { + if (DEBUG) { + assertOnIcThread(); + } + + if (type == SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT) { + if (mNeedSync) { + icSyncShadowText(); + } + return; + } + + switch (type) { + case SessionTextInput.EditableListener.NOTIFY_IME_OF_FOCUS: + if (mFocusedChild != null) { + // Already focused, so blur first. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ false); + } + + mFocusedChild = child; + mNeedSync = false; + mText.syncShadowText(/* listener */ null); + + // Most of the time notifyIMEContext comes _before_ notifyIME, but sometimes it + // comes _after_ notifyIME. In that case, the state is disabled here, and + // notifyIMEContext is responsible for calling restartInput. + if (mIMEState == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + mIMEState = SessionTextInput.EditableListener.IME_STATE_UNKNOWN; + } else { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true); + } + break; + + case SessionTextInput.EditableListener.NOTIFY_IME_OF_BLUR: + if (mFocusedChild != null) { + mFocusedChild = null; + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_BLUR, /* toggleSoftInput */ true); + } + break; + + case SessionTextInput.EditableListener.NOTIFY_IME_OPEN_VKB: + toggleSoftInput(/* force */ true, mIMEState); + return; // Don't notify listener. + + case SessionTextInput.EditableListener.NOTIFY_IME_TO_COMMIT_COMPOSITION: + { + // 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. + // + // Nevertheless, if we somehow lost the composition, we must force the + // keyboard to reset. + if (isComposing(mText.getShadowText())) { + // Still have composition; no need to reset. + return; // Don't notify listener. + } + // No longer have composition; perform reset. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ false); + return; // Don't notify listener. + } + + case SessionTextInput.EditableListener.NOTIFY_IME_OF_TOKEN: + case SessionTextInput.EditableListener.NOTIFY_IME_REPLY_EVENT: + case SessionTextInput.EditableListener.NOTIFY_IME_TO_CANCEL_COMPOSITION: + default: + throw new IllegalArgumentException("Invalid notifyIME type: " + type); + } + + if (mListener != null) { + mListener.notifyIME(type); + } + } + + @Override // IGeckoEditableParent + public void notifyIMEContext( + final IBinder token, + @IMEState final int state, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + @IMEContextFlags final int flags) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("notifyIMEContext("); + sb.append(getConstantName(SessionTextInput.EditableListener.class, "IME_STATE_", state)) + .append(", type=\"") + .append(typeHint) + .append("\", inputmode=\"") + .append(modeHint) + .append("\", autocapitalize=\"") + .append(autocapitalize) + .append("\", flags=0x") + .append(Integer.toHexString(flags)) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + // Regular notifyIMEContext calls all come from the parent process (with the default child), + // so always allow calls from there. We can get additional notifyIMEContext calls during + // a session transfer; calls in those cases can come from child processes, and we must + // perform a token check in that situation. + if (token != mDefaultChild.asBinder() && !binderCheckToken(token, /* allowNull */ false)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icNotifyIMEContext(state, typeHint, modeHint, actionHint, autocapitalize, flags); + } + }); + } + + /* package */ void icNotifyIMEContext( + @IMEState final int originalState, + final String typeHint, + final String modeHint, + final String actionHint, + final String autocapitalize, + @IMEContextFlags final int flags) { + if (DEBUG) { + assertOnIcThread(); + } + + // 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. + final int state; + if ((typeHint != null + && (typeHint.equalsIgnoreCase("date") + || typeHint.equalsIgnoreCase("time") + || typeHint.equalsIgnoreCase("month") + || typeHint.equalsIgnoreCase("week") + || typeHint.equalsIgnoreCase("datetime-local"))) + || (modeHint != null && modeHint.equals("none"))) { + state = SessionTextInput.EditableListener.IME_STATE_DISABLED; + } else { + state = originalState; + } + + final int oldState = mIMEState; + mIMEState = state; + mIMETypeHint = (typeHint == null) ? "" : typeHint; + mIMEModeHint = (modeHint == null) ? "" : modeHint; + mIMEActionHint = (actionHint == null) ? "" : actionHint; + mIMEAutocapitalize = (autocapitalize == null) ? "" : autocapitalize; + mIMEFlags = flags; + + if (mListener != null) { + mListener.notifyIMEContext(state, typeHint, modeHint, actionHint, flags); + } + + if (mFocusedChild == null) { + // We have no focus. + return; + } + + if ((flags & SessionTextInput.EditableListener.IME_FOCUS_NOT_CHANGED) != 0) { + if (DEBUG) { + final StringBuilder sb = new StringBuilder("icNotifyIMEContext: "); + sb.append("focus isn't changed. oldState=") + .append(oldState) + .append(", newState=") + .append(state); + Log.d(LOGTAG, sb.toString()); + } + if (((oldState == SessionTextInput.EditableListener.IME_STATE_ENABLED + || oldState == SessionTextInput.EditableListener.IME_STATE_PASSWORD) + && state == SessionTextInput.EditableListener.IME_STATE_DISABLED) + || (oldState == SessionTextInput.EditableListener.IME_STATE_DISABLED + && (state == SessionTextInput.EditableListener.IME_STATE_ENABLED + || state == SessionTextInput.EditableListener.IME_STATE_PASSWORD))) { + // Even if focus isn't changed, software keyboard state is changed. + // We have to show or dismiss it. + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ true); + return; + } + } + + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + // When focus is being lost, icNotifyIME with NOTIFY_IME_OF_BLUR + // will dismiss it. + // So ignore to control software keyboard at this time. + return; + } + + // We changed state while focused. If the old state is unknown, it means this + // notifyIMEContext call came _after_ the notifyIME call, so we need to call + // restartInput(FOCUS) here (see comment in icNotifyIME). Otherwise, this change + // counts as a content change. + if (oldState == SessionTextInput.EditableListener.IME_STATE_UNKNOWN) { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_FOCUS, /* toggleSoftInput */ true); + } else if (oldState != SessionTextInput.EditableListener.IME_STATE_DISABLED) { + icRestartInput( + GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE, + /* toggleSoftInput */ false); + } + } + + private void icRestartInput( + @GeckoSession.RestartReason final int reason, final boolean toggleSoftInput) { + if (DEBUG) { + assertOnIcThread(); + } + + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + if (DEBUG) { + Log.d(LOGTAG, "restartInput(" + reason + ", " + toggleSoftInput + ')'); + } + + final GeckoSession session = mSession.get(); + if (session != null) { + session.getTextInput().getDelegate().restartInput(session, reason); + } + + if (!toggleSoftInput) { + return; + } + postToInputConnection( + new Runnable() { + @Override + public void run() { + int state = mIMEState; + if (reason == GeckoSession.TextInputDelegate.RESTART_REASON_BLUR + && mFocusedChild == null) { + // On blur, notifyIMEContext() is called after notifyIME(). Therefore, + // mIMEState is not up-to-date here and we need to override it. + state = SessionTextInput.EditableListener.IME_STATE_DISABLED; + } + toggleSoftInput(/* force */ false, state); + } + }); + } + }); + } + + public void onCreateInputConnection(final EditorInfo outAttrs) { + final int state = mIMEState; + final String typeHint = mIMETypeHint; + final String modeHint = mIMEModeHint; + final String actionHint = mIMEActionHint; + final String autocapitalize = mIMEAutocapitalize; + final int flags = mIMEFlags; + + // Some keyboards require us to fill out outAttrs even if we return null. + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE; + outAttrs.actionLabel = null; + + if (modeHint.equals("none")) { + // inputmode=none hides VKB at force. + outAttrs.inputType = InputType.TYPE_NULL; + toggleSoftInput(/* force */ true, SessionTextInput.EditableListener.IME_STATE_DISABLED); + return; + } + + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + outAttrs.inputType = InputType.TYPE_NULL; + toggleSoftInput(/* force */ false, state); + return; + } + + // We give priority to typeHint so that content authors can't annoy + // users by doing dumb things like opening the numeric keyboard for + // an email form field. + outAttrs.inputType = InputType.TYPE_CLASS_TEXT; + if (state == SessionTextInput.EditableListener.IME_STATE_PASSWORD + || "password".equalsIgnoreCase(typeHint)) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_PASSWORD; + } else if (typeHint.equalsIgnoreCase("url") || modeHint.equals("mozAwesomebar")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_URI; + } else if (typeHint.equalsIgnoreCase("email")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + } else if (typeHint.equalsIgnoreCase("tel")) { + outAttrs.inputType = InputType.TYPE_CLASS_PHONE; + } else if (typeHint.equalsIgnoreCase("number") || typeHint.equalsIgnoreCase("range")) { + outAttrs.inputType = + InputType.TYPE_CLASS_NUMBER + | InputType.TYPE_NUMBER_VARIATION_NORMAL + | InputType.TYPE_NUMBER_FLAG_DECIMAL; + } else { + // We look at modeHint + if (modeHint.equals("tel")) { + outAttrs.inputType = InputType.TYPE_CLASS_PHONE; + } else if (modeHint.equals("url")) { + outAttrs.inputType = InputType.TYPE_TEXT_VARIATION_URI; + } else if (modeHint.equals("email")) { + outAttrs.inputType |= InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + } else if (modeHint.equals("numeric")) { + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_NORMAL; + } else if (modeHint.equals("decimal")) { + outAttrs.inputType = InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_FLAG_DECIMAL; + } 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 (autocapitalize.equals("characters")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS; + } else if (autocapitalize.equals("none")) { + // not set anymore. + } else if (autocapitalize.equals("sentences")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } else if (autocapitalize.equals("words")) { + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_WORDS; + } else if (modeHint.length() == 0 + && (outAttrs.inputType & InputType.TYPE_TEXT_FLAG_IME_MULTI_LINE) != 0 + && !typeHint.equalsIgnoreCase("text")) { + // auto-capitalized mode is the default for types other than text (bug 871884) + // except to password, url and email. + outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } + + if (actionHint.equals("enter")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_NONE; + } else if (actionHint.equals("go")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_GO; + } else if (actionHint.equals("done")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_DONE; + } else if (actionHint.equals("next") || actionHint.equals("maybenext")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_NEXT; + } else if (actionHint.equals("previous")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_PREVIOUS; + } else if (actionHint.equals("search") || typeHint.equals("search")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEARCH; + } else if (actionHint.equals("send")) { + outAttrs.imeOptions = EditorInfo.IME_ACTION_SEND; + } else if (actionHint.length() > 0) { + if (DEBUG) Log.w(LOGTAG, "Unexpected actionHint=\"" + actionHint + "\""); + outAttrs.actionLabel = actionHint; + } + + if ((flags & SessionTextInput.EditableListener.IME_FLAG_PRIVATE_BROWSING) != 0) { + outAttrs.imeOptions |= InputMethods.IME_FLAG_NO_PERSONALIZED_LEARNING; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1 && typeHint.length() == 0) { + // contenteditable allows image insertion. + outAttrs.contentMimeTypes = new String[] {"image/gif", "image/jpeg", "image/png"}; + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final Spanned currentText = mText.getCurrentText(); + outAttrs.initialSelStart = Selection.getSelectionStart(currentText); + outAttrs.initialSelEnd = Selection.getSelectionEnd(currentText); + outAttrs.setInitialSurroundingText(currentText); + } + + toggleSoftInput(/* force */ false, state); + } + + /* package */ void toggleSoftInput(final boolean force, final int state) { + if (DEBUG) { + Log.d(LOGTAG, "toggleSoftInput"); + } + // Can be called from UI or IC thread. + final int flags = mIMEFlags; + + // There are three paths that toggleSoftInput() can be called: + // 1) through calling restartInput(), which then indirectly calls + // onCreateInputConnection() and then toggleSoftInput(). + // 2) through calling toggleSoftInput() directly from restartInput(). + // This path is the fallback in case 1) does not happen. + // 3) through a system-generated onCreateInputConnection() call when the activity + // is restored from background, which then calls toggleSoftInput(). + // mSoftInputReentrancyGuard is needed to ensure that between the different paths, + // the soft input is only toggled exactly once. + + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + try { + final int reentrancyGuard = mSoftInputReentrancyGuard.incrementAndGet(); + final boolean isReentrant = reentrancyGuard > 1; + + // 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 + // show/hide the keyboard because the find box has the focus and is taking input from + // the keyboard. + final GeckoSession session = mSession.get(); + + if (session == null) { + return; + } + + final View view = session.getTextInput().getView(); + final boolean isFocused = (view == null) || view.hasFocus(); + + final boolean isUserAction = + ((flags & SessionTextInput.EditableListener.IME_FLAG_USER_ACTION) != 0); + + if (!force && (isReentrant || !isFocused || !isUserAction)) { + if (DEBUG) { + Log.d( + LOGTAG, + "toggleSoftInput: no-op, reentrant=" + + isReentrant + + ", focused=" + + isFocused + + ", user=" + + isUserAction); + } + return; + } + if (state == SessionTextInput.EditableListener.IME_STATE_DISABLED) { + session.getTextInput().getDelegate().hideSoftInput(session); + return; + } + { + final GeckoBundle bundle = new GeckoBundle(); + // This bit is subtle. We want to force-zoom to the input + // if we're _not_ force-showing the virtual keyboard. + // + // We only force-show the virtual keyboard as a result of + // something that _doesn't_ switch the focus, and we don't + // want to move the view out of the focused editor unless + // we _actually_ show toggle the keyboard. + bundle.putBoolean("force", !force); + session.getEventDispatcher().dispatch("GeckoView:ZoomToInput", bundle); + } + session.getTextInput().getDelegate().showSoftInput(session); + } finally { + mSoftInputReentrancyGuard.decrementAndGet(); + } + } + }); + } + + @Override // IGeckoEditableParent + public void onSelectionChange( + final IBinder token, final int start, final int end, final boolean causedOnlyByComposition) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onSelectionChange("); + sb.append(start) + .append(", ") + .append(end) + .append(", ") + .append(causedOnlyByComposition) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (mIgnoreSelectionChange) { + mIgnoreSelectionChange = false; + } else { + mText.currentSetSelection(start, end); + } + + // We receive selection change notification after receiving replies for pending + // events, so we can reset text change bounds at this point. + mLastTextChangeStart = Integer.MAX_VALUE; + mLastTextChangeOldEnd = -1; + mLastTextChangeNewEnd = -1; + mLastTextChangeReplacedSelection = false; + + if (causedOnlyByComposition) { + // It is unnecessary to sync shadow text since this change is by composition from Java + // side. + return; + } + + // It is ready to synchronize Java text with Gecko text when no more input events is + // dispatched. + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + icSyncShadowText(); + } + }); + } + + private boolean geckoIsSameText(final int start, final int oldEnd, final CharSequence newText) { + return oldEnd - start == newText.length() + && TextUtils.regionMatches(mText.getCurrentText(), start, newText, 0, oldEnd - start); + } + + @Override // IGeckoEditableParent + public void onTextChange( + final IBinder token, + final CharSequence text, + final int start, + final int unboundedOldEnd, + final boolean causedOnlyByComposition) { + // On Gecko or binder thread. + if (DEBUG) { + final StringBuilder sb = new StringBuilder("onTextChange("); + debugAppend(sb, text) + .append(", ") + .append(start) + .append(", ") + .append(unboundedOldEnd) + .append(")"); + Log.d(LOGTAG, sb.toString()); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + if (unboundedOldEnd >= Integer.MAX_VALUE / 2) { + // Integer.MAX_VALUE / 2 is a magic number to synchronize all. + // (See GeckoEditableSupport::FlushIMEText.) + // Previous text transactions are unnecessary now, so we have to ignore it. + mActions.clear(); + } + + final int currentLength = mText.getCurrentText().length(); + final int oldEnd = unboundedOldEnd > currentLength ? currentLength : unboundedOldEnd; + final int newEnd = start + text.length(); + + if (start == 0 && unboundedOldEnd > currentLength && !causedOnlyByComposition) { + // | oldEnd > currentLength | signals entire text is cleared (e.g. for + // newly-focused editors). Simply replace the text in that case; 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; + + mLastTextChangeStart = Integer.MAX_VALUE; + mLastTextChangeOldEnd = -1; + mLastTextChangeNewEnd = -1; + mLastTextChangeReplacedSelection = false; + + } else if (!geckoIsSameText(start, oldEnd, text)) { + final Spanned currentText = mText.getCurrentText(); + final int selStart = Selection.getSelectionStart(currentText); + final int selEnd = Selection.getSelectionEnd(currentText); + + // True if the selection was in the middle of the replaced text; in that case + // we don't know where to place the selection after replacement, and must rely + // on the Gecko selection. + mLastTextChangeReplacedSelection |= + (selStart >= start && selStart <= oldEnd) || (selEnd >= start && selEnd <= oldEnd); + + // 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); + + mLastTextChangeStart = Math.min(start, mLastTextChangeStart); + mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd); + mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd); + + } else { + // 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. + final Action action = mActions.peek(); + mIgnoreSelectionChange = + mIgnoreSelectionChange + || (action != null + && (action.mType == Action.TYPE_REPLACE_TEXT + || action.mType == Action.TYPE_SET_SPAN + || action.mType == Action.TYPE_REMOVE_SPAN)); + + mLastTextChangeStart = Math.min(start, mLastTextChangeStart); + mLastTextChangeOldEnd = Math.max(oldEnd, mLastTextChangeOldEnd); + mLastTextChangeNewEnd = Math.max(newEnd, mLastTextChangeNewEnd); + } + + // onTextChange is always followed by onSelectionChange, so we let + // onSelectionChange schedule a shadow text sync. + } + + @Override // IGeckoEditableParent + public void onDefaultKeyEvent(final IBinder token, final KeyEvent event) { + // On Gecko or binder thread. + if (DEBUG) { + final 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()); + } + + // Allow default key processing even if we're not focused. + if (!binderCheckToken(token, /* allowNull */ true)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.onDefaultKeyEvent(event); + } + }); + } + + @Override // IGeckoEditableParent + public void updateCompositionRects( + final IBinder token, final RectF[] rects, final RectF caretRect) { + // On Gecko or binder thread. + if (DEBUG) { + Log.d(LOGTAG, "updateCompositionRects(rects.length = " + rects.length + ")"); + } + + if (!binderCheckToken(token, /* allowNull */ false)) { + return; + } + + mIcPostHandler.post( + new Runnable() { + @Override + public void run() { + if (mListener == null) { + return; + } + mListener.updateCompositionRects(rects, caretRect); + } + }); + } + + // InvocationHandler interface + + static String getConstantName(final Class<?> cls, final String prefix, final Object value) { + for (final Field fld : cls.getDeclaredFields()) { + try { + if (fld.getName().startsWith(prefix) && fld.get(null).equals(value)) { + return fld.getName(); + } + } catch (final IllegalAccessException e) { + } + } + return String.valueOf(value); + } + + private static String getPrintableChar(final char chr) { + if (chr >= 0x20 && chr <= 0x7e) { + return String.valueOf(chr); + } else if (chr == '\n') { + return "\u21b2"; + } + return String.format("\\u%04x", (int) chr); + } + + static StringBuilder debugAppend(final StringBuilder sb, final Object obj) { + if (obj == null) { + sb.append("null"); + } else if (obj instanceof GeckoEditable) { + sb.append("GeckoEditable"); + } else if (obj instanceof GeckoEditableChild) { + sb.append("GeckoEditableChild"); + } else if (Proxy.isProxyClass(obj.getClass())) { + debugAppend(sb, Proxy.getInvocationHandler(obj)); + } else if (obj instanceof Character) { + sb.append('\'').append(getPrintableChar((Character) obj)).append('\''); + } else if (obj instanceof CharSequence) { + final String str = obj.toString(); + sb.append('"'); + for (int i = 0; i < str.length(); i++) { + final char chr = str.charAt(i); + if (chr >= 0x20 && chr <= 0x7e) { + sb.append(chr); + } else { + sb.append(getPrintableChar(chr)); + } + } + sb.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(final Object proxy, final Method method, final Object[] args) + throws Throwable { + final 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(); + } + + final Object ret = method.invoke(target, args); + if (DEBUG) { + final StringBuilder log = new StringBuilder(method.getName()); + log.append("("); + if (args != null) { + for (final 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(final 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(final Object what, final int start, final int end, final int flags) { + icOfferAction(Action.newSetSpan(what, start, end, flags)); + } + + // Appendable interface + + @Override + public Editable append(final CharSequence text) { + return replace(mProxy.length(), mProxy.length(), text, 0, text.length()); + } + + @Override + public Editable append(final CharSequence text, final int start, final int end) { + return replace(mProxy.length(), mProxy.length(), text, start, end); + } + + @Override + public Editable append(final 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(final 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( + final int st, final int en, final CharSequence source, final int start, final 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(final int st, final int en) { + return replace(st, en, "", 0, 0); + } + + @Override + public Editable insert(final int where, final CharSequence text, final int start, final int end) { + return replace(where, where, text, start, end); + } + + @Override + public Editable insert(final int where, final CharSequence text) { + return replace(where, where, text, 0, text.length()); + } + + @Override + public Editable replace(final int st, final int en, final CharSequence text) { + return replace(st, en, text, 0, text.length()); + } + + /* GetChars interface */ + + @Override + public void getChars(final int start, final int end, final char[] dest, final 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(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanFlags(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public int getSpanStart(final Object tag) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public <T> T[] getSpans(final int start, final int end, final 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(final int start, final int limit, final Class type) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + /* CharSequence interface */ + + @Override + public char charAt(final 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(final int start, final int end) { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + @Override + public String toString() { + throw new UnsupportedOperationException("method must be called through mProxy"); + } + + public boolean onKeyPreIme( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return false; + } + + public boolean onKeyDown( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return processKey(view, KeyEvent.ACTION_DOWN, keyCode, event); + } + + public boolean onKeyUp( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return processKey(view, KeyEvent.ACTION_UP, keyCode, event); + } + + public boolean onKeyMultiple( + final @Nullable View view, + final int keyCode, + final int repeatCount, + final @NonNull 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(view, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_UNKNOWN, charEvent) + || !processKey(view, KeyEvent.ACTION_UP, KeyEvent.KEYCODE_UNKNOWN, charEvent)) { + return false; + } + } + return true; + } + + for (int i = 0; i < repeatCount; i++) { + if (!processKey(view, KeyEvent.ACTION_DOWN, keyCode, event) + || !processKey(view, KeyEvent.ACTION_UP, keyCode, event)) { + return false; + } + } + + return true; + } + + public boolean onKeyLongPress( + final @Nullable View view, final int keyCode, final @NonNull KeyEvent event) { + return false; + } + + /** Get a key that represents a given character. */ + private static 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(final int metaState) { + return c; + } + }; + } + + private boolean processKey( + final @Nullable View view, + final int action, + final int keyCode, + final @NonNull KeyEvent event) { + if (keyCode > KeyEvent.getMaxKeyCode() || !shouldProcessKey(keyCode, event)) { + return false; + } + + postToInputConnection( + new Runnable() { + @Override + public void run() { + sendKeyEvent(view, action, event); + } + }); + return true; + } + + private static boolean shouldProcessKey(final int keyCode, final 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 static boolean isComposing(final Spanned text) { + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + return true; + } + } + + return false; + } + + private static int getComposingStart(final Spanned text) { + int composingStart = Integer.MAX_VALUE; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + composingStart = Math.min(composingStart, text.getSpanStart(span)); + } + } + + return composingStart; + } + + private static int getComposingEnd(final Spanned text) { + int composingEnd = -1; + final Object[] spans = text.getSpans(0, text.length(), Object.class); + for (final Object span : spans) { + if ((text.getSpanFlags(span) & Spanned.SPAN_COMPOSING) != 0) { + composingEnd = Math.max(composingEnd, text.getSpanEnd(span)); + } + } + + return composingEnd; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java new file mode 100644 index 0000000000..ec53d2803a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoFontScaleListener.java @@ -0,0 +1,172 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.provider.Settings; +import android.util.Log; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * A class that automatically adjusts font size settings for web content in Gecko in accordance with + * the device's OS font scale setting. + * + * @see android.provider.Settings.System#FONT_SCALE + */ +/* package */ final class GeckoFontScaleListener extends ContentObserver { + private static final String LOGTAG = "GeckoFontScaleListener"; + + private static final float DEFAULT_FONT_SCALE = 1.0f; + + // We're referencing the *application* context, so this is in fact okay. + @SuppressLint("StaticFieldLeak") + private static final GeckoFontScaleListener sInstance = new GeckoFontScaleListener(); + + private Context mApplicationContext; + private GeckoRuntimeSettings mSettings; + + private boolean mAttached; + private boolean mEnabled; + private boolean mRunning; + + private float mPrevGeckoFontScale; + + public static GeckoFontScaleListener getInstance() { + return sInstance; + } + + private GeckoFontScaleListener() { + // Ensure the ContentObserver callback runs on the UI thread. + super(ThreadUtils.getUiHandler()); + } + + /** + * Prepare the GeckoFontScaleListener for usage. If it has been previously enabled, it will now + * start actively working. + */ + public void attachToContext(final Context context, final GeckoRuntimeSettings settings) { + ThreadUtils.assertOnUiThread(); + + if (mAttached) { + Log.w(LOGTAG, "Already attached!"); + return; + } + + mAttached = true; + mSettings = settings; + mApplicationContext = context.getApplicationContext(); + onEnabledChange(); + } + + /** + * Detaches the context and also stops the GeckoFontScaleListener if it was previously enabled. + * This will also restore the previously used font size settings. + */ + public void detachFromContext() { + ThreadUtils.assertOnUiThread(); + + if (!mAttached) { + Log.w(LOGTAG, "Already detached!"); + return; + } + + stop(); + mApplicationContext = null; + mSettings = null; + mAttached = false; + } + + /** + * Controls whether the GeckoFontScaleListener should automatically adjust font sizes for web + * content in Gecko. When disabling, this will restore the previously used font size settings. + * + * <p>This method can be called at any time, but the GeckoFontScaleListener won't start actively + * adjusting font sizes until it has been attached to a context. + * + * @param enabled True if automatic font size setting should be enabled. + */ + public void setEnabled(final boolean enabled) { + ThreadUtils.assertOnUiThread(); + mEnabled = enabled; + onEnabledChange(); + } + + /** + * Get whether the GeckoFontScaleListener is currently enabled. + * + * @return True if the GeckoFontScaleListener is currently enabled. + */ + public boolean getEnabled() { + return mEnabled; + } + + private void onEnabledChange() { + if (!mAttached) { + return; + } + + if (mEnabled) { + start(); + } else { + stop(); + } + } + + private void start() { + if (mRunning) { + return; + } + + mPrevGeckoFontScale = mSettings.getFontSizeFactor(); + final ContentResolver contentResolver = mApplicationContext.getContentResolver(); + final Uri fontSizeSetting = Settings.System.getUriFor(Settings.System.FONT_SCALE); + contentResolver.registerContentObserver(fontSizeSetting, false, this); + onSystemFontScaleChange(contentResolver, false); + + mRunning = true; + } + + private void stop() { + if (!mRunning) { + return; + } + + final ContentResolver contentResolver = mApplicationContext.getContentResolver(); + contentResolver.unregisterContentObserver(this); + onSystemFontScaleChange(contentResolver, /*stopping*/ true); + + mRunning = false; + } + + private void onSystemFontScaleChange( + final ContentResolver contentResolver, final boolean stopping) { + float fontScale; + + if (!stopping) { // Either we were enabled, or else the system font scale changed. + fontScale = + Settings.System.getFloat(contentResolver, Settings.System.FONT_SCALE, DEFAULT_FONT_SCALE); + // Older Android versions don't sanitize the FONT_SCALE value. See Bug 1656078. + if (fontScale < 0) { + fontScale = DEFAULT_FONT_SCALE; + } + } else { // We were turned off. + fontScale = mPrevGeckoFontScale; + } + + mSettings.setFontSizeFactorInternal(fontScale); + } + + @UiThread // See constructor. + @Override + public void onChange(final boolean selfChange) { + onSystemFontScaleChange(mApplicationContext.getContentResolver(), false); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java new file mode 100644 index 0000000000..2d2f2d8dd3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputConnection.java @@ -0,0 +1,829 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Matrix; +import android.graphics.RectF; +import android.media.AudioManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.Selection; +import android.text.SpannableString; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.CursorAnchorInfo; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputContentInfo; +import androidx.annotation.NonNull; +import java.io.ByteArrayOutputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import org.mozilla.gecko.Clipboard; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.util.ThreadUtils; + +/* package */ final class GeckoInputConnection extends BaseInputConnection + implements SessionTextInput.InputConnectionClient, SessionTextInput.EditableListener { + + private static final boolean DEBUG = false; + protected static final String LOGTAG = "GeckoInputConnection"; + + private static final String CUSTOM_HANDLER_TEST_METHOD = "testInputConnection"; + private static final String CUSTOM_HANDLER_TEST_CLASS = + "org.mozilla.gecko.tests.components.GeckoViewComponent$TextInput"; + + private static final int INLINE_IME_MIN_DISPLAY_SIZE = 480; + + private static Handler sBackgroundHandler; + + // Managed only by notifyIMEContext; see comments in notifyIMEContext + @IMEState private int mIMEState; + private String mIMEActionHint = ""; + private int mLastSelectionStart; + private int mLastSelectionEnd; + + private String mCurrentInputMethod = ""; + + private final GeckoSession mSession; + private final View mView; + private final SessionTextInput.EditableClient mEditableClient; + protected int mBatchEditCount; + private ExtractedTextRequest mUpdateRequest; + private final InputConnection mKeyInputConnection; + private CursorAnchorInfo.Builder mCursorAnchorInfoBuilder; + + public static SessionTextInput.InputConnectionClient create( + final GeckoSession session, + final View targetView, + final SessionTextInput.EditableClient editable) { + SessionTextInput.InputConnectionClient ic = + new GeckoInputConnection(session, targetView, editable); + if (DEBUG) { + ic = wrapForDebug(ic); + } + return ic; + } + + private static SessionTextInput.InputConnectionClient wrapForDebug( + final SessionTextInput.InputConnectionClient ic) { + final InvocationHandler handler = + new InvocationHandler() { + private final StringBuilder mCallLevel = new StringBuilder(); + + @Override + public Object invoke(final Object proxy, final Method method, final Object[] args) + throws Throwable { + final StringBuilder log = new StringBuilder(mCallLevel); + log.append("> ").append(method.getName()).append("("); + if (args != null) { + for (int i = 0; i < args.length; i++) { + final Object arg = args[i]; + // translate argument values to constant names + if ("notifyIME".equals(method.getName()) && i == 0) { + log.append( + GeckoEditable.getConstantName( + SessionTextInput.EditableListener.class, "NOTIFY_IME_", arg)); + } else if ("notifyIMEContext".equals(method.getName()) && i == 0) { + log.append( + GeckoEditable.getConstantName( + SessionTextInput.EditableListener.class, "IME_STATE_", arg)); + } else { + GeckoEditable.debugAppend(log, arg); + } + log.append(", "); + } + if (args.length > 0) { + log.setLength(log.length() - 2); + } + } + log.append(")"); + Log.d(LOGTAG, log.toString()); + + mCallLevel.append(' '); + Object ret = method.invoke(ic, args); + if (ret == ic) { + ret = proxy; + } + mCallLevel.setLength(Math.max(0, mCallLevel.length() - 1)); + + log.setLength(mCallLevel.length()); + log.append("< ").append(method.getName()); + if (!method.getReturnType().equals(Void.TYPE)) { + GeckoEditable.debugAppend(log.append(": "), ret); + } + Log.d(LOGTAG, log.toString()); + return ret; + } + }; + + return (SessionTextInput.InputConnectionClient) + Proxy.newProxyInstance( + GeckoInputConnection.class.getClassLoader(), + new Class<?>[] { + InputConnection.class, + SessionTextInput.InputConnectionClient.class, + SessionTextInput.EditableListener.class + }, + handler); + } + + protected GeckoInputConnection( + final GeckoSession session, + final View targetView, + final SessionTextInput.EditableClient editable) { + super(targetView, true); + mSession = session; + mView = targetView; + mEditableClient = editable; + mIMEState = IME_STATE_DISABLED; + // InputConnection that sends keys for plugins, which don't have full editors + mKeyInputConnection = new BaseInputConnection(targetView, false); + } + + @Override + public synchronized boolean beginBatchEdit() { + mBatchEditCount++; + if (mBatchEditCount == 1) { + mEditableClient.setBatchMode(true); + } + return true; + } + + @Override + public synchronized boolean endBatchEdit() { + if (mBatchEditCount <= 0) { + Log.w(LOGTAG, "endBatchEdit() called, but mBatchEditCount <= 0?!"); + return true; + } + + mBatchEditCount--; + if (mBatchEditCount != 0) { + return true; + } + + // setBatchMode will call onTextChange and/or onSelectionChange for us. + mEditableClient.setBatchMode(false); + return true; + } + + @Override + public Editable getEditable() { + return mEditableClient.getEditable(); + } + + @Override + public boolean performContextMenuAction(final int id) { + final View view = getView(); + final Editable editable = getEditable(); + if (view == null || editable == null) { + return false; + } + final int selStart = Selection.getSelectionStart(editable); + final int selEnd = Selection.getSelectionEnd(editable); + + switch (id) { + case android.R.id.selectAll: + setSelection(0, editable.length()); + break; + case android.R.id.cut: + // If selection is empty, we'll select everything + if (selStart == selEnd) { + // Fill the clipboard + Clipboard.setText(view.getContext(), editable); + editable.clear(); + } else { + Clipboard.setText( + view.getContext(), + editable.subSequence(Math.min(selStart, selEnd), Math.max(selStart, selEnd))); + editable.delete(selStart, selEnd); + } + break; + case android.R.id.paste: + final String text = Clipboard.getText(view.getContext()); + if (text != null) { + commitText(text, 1); + } + break; + case android.R.id.copy: + // Copy the current selection or the empty string if nothing is selected. + final String copiedText = + selStart == selEnd + ? "" + : editable + .toString() + .substring(Math.min(selStart, selEnd), Math.max(selStart, selEnd)); + Clipboard.setText(view.getContext(), copiedText); + break; + } + return true; + } + + @Override + public boolean performEditorAction(final int editorAction) { + if (editorAction == EditorInfo.IME_ACTION_PREVIOUS && !mIMEActionHint.equals("previous")) { + // This action is [Previous] key on FireTV's keyboard. + // [Previous] closes software keyboard, and don't generate any keyboard event. + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().hideSoftInput(mSession); + } + }); + return true; + } + return super.performEditorAction(editorAction); + } + + @Override + public ExtractedText getExtractedText(final ExtractedTextRequest req, final int flags) { + if (req == null) return null; + + if ((flags & GET_EXTRACTED_TEXT_MONITOR) != 0) mUpdateRequest = req; + + final Editable editable = getEditable(); + if (editable == null) { + return null; + } + final int selStart = Selection.getSelectionStart(editable); + final int selEnd = Selection.getSelectionEnd(editable); + + final ExtractedText extract = new ExtractedText(); + extract.flags = 0; + extract.partialStartOffset = -1; + extract.partialEndOffset = -1; + extract.selectionStart = selStart; + extract.selectionEnd = selEnd; + extract.startOffset = 0; + if ((req.flags & GET_TEXT_WITH_STYLES) != 0) { + extract.text = new SpannableString(editable); + } else { + extract.text = editable.toString(); + } + return extract; + } + + @Override // SessionTextInput.InputConnectionClient + public View getView() { + return mView; + } + + @NonNull + /* package */ GeckoSession.TextInputDelegate getInputDelegate() { + return mSession.getTextInput().getDelegate(); + } + + @Override // SessionTextInput.EditableListener + public void onTextChange() { + final Editable editable = getEditable(); + if (mUpdateRequest == null || editable == null) { + return; + } + + final ExtractedTextRequest request = mUpdateRequest; + final ExtractedText extractedText = new ExtractedText(); + extractedText.flags = 0; + // Update the entire Editable range + extractedText.partialStartOffset = -1; + extractedText.partialEndOffset = -1; + extractedText.selectionStart = Selection.getSelectionStart(editable); + extractedText.selectionEnd = Selection.getSelectionEnd(editable); + extractedText.startOffset = 0; + if ((request.flags & GET_TEXT_WITH_STYLES) != 0) { + extractedText.text = new SpannableString(editable); + } else { + extractedText.text = editable.toString(); + } + + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().updateExtractedText(mSession, request, extractedText); + } + }); + } + + @Override // SessionTextInput.EditableListener + public void onSelectionChange() { + + final Editable editable = getEditable(); + if (editable != null) { + mLastSelectionStart = Selection.getSelectionStart(editable); + mLastSelectionEnd = Selection.getSelectionEnd(editable); + notifySelectionChange(mLastSelectionStart, mLastSelectionEnd); + } + } + + private void notifySelectionChange(final int start, final int end) { + final Editable editable = getEditable(); + if (editable == null) { + return; + } + + final int compositionStart = getComposingSpanStart(editable); + final int compositionEnd = getComposingSpanEnd(editable); + + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate() + .updateSelection(mSession, start, end, compositionStart, compositionEnd); + } + }); + } + + @Override // SessionTextInput.EditableListener + public void onDiscardComposition() { + final View view = getView(); + if (view == null) { + return; + } + + // InputMethodManager.updateSelection will remove composition + // on most IMEs. But ATOK series do nothing. So we have to + // restart input method to remove composition as workaround. + if (!InputMethods.needsRestartInput(InputMethods.getCurrentInputMethod(view.getContext()))) { + return; + } + + view.post( + new Runnable() { + @Override + public void run() { + getInputDelegate() + .restartInput( + mSession, GeckoSession.TextInputDelegate.RESTART_REASON_CONTENT_CHANGE); + } + }); + } + + @TargetApi(21) + @Override // SessionTextInput.EditableListener + public void updateCompositionRects(final RectF[] rects, final RectF caretRect) { + if (!(Build.VERSION.SDK_INT >= 21)) { + return; + } + + final View view = getView(); + if (view == null) { + return; + } + + final Editable content = getEditable(); + if (content == null) { + return; + } + + final int composingStart = getComposingSpanStart(content); + final int composingEnd = getComposingSpanEnd(content); + if (composingStart < 0 || composingEnd < 0) { + if (DEBUG) { + Log.d(LOGTAG, "No composition for updates"); + } + return; + } + + final CharSequence composition = content.subSequence(composingStart, composingEnd); + + view.post( + new Runnable() { + @Override + public void run() { + updateCompositionRectsOnUi(view, rects, caretRect, composition); + } + }); + } + + @TargetApi(21) + /* package */ void updateCompositionRectsOnUi( + final View view, final RectF[] rects, final RectF caretRect, final CharSequence composition) { + if (mCursorAnchorInfoBuilder == null) { + mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder(); + } + mCursorAnchorInfoBuilder.reset(); + + final Matrix matrix = new Matrix(); + mSession.getClientToScreenOffsetMatrix(matrix); + mCursorAnchorInfoBuilder.setMatrix(matrix); + + for (int i = 0; i < rects.length; i++) { + mCursorAnchorInfoBuilder.addCharacterBounds( + i, + rects[i].left, + rects[i].top, + rects[i].right, + rects[i].bottom, + CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); + } + + mCursorAnchorInfoBuilder.setComposingText(0, composition); + + if (!caretRect.isEmpty()) { + // Gecko doesn't provide baseline information of caret. + mCursorAnchorInfoBuilder.setInsertionMarkerLocation( + caretRect.left, + caretRect.top, + caretRect.bottom, + caretRect.bottom, + CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION); + } + + final CursorAnchorInfo info = mCursorAnchorInfoBuilder.build(); + getView() + .post( + new Runnable() { + @Override + public void run() { + getInputDelegate().updateCursorAnchorInfo(mSession, info); + } + }); + } + + @Override + public boolean requestCursorUpdates(final int cursorUpdateMode) { + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_IMMEDIATE) != 0) { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.ONE_SHOT); + } + + if ((cursorUpdateMode & InputConnection.CURSOR_UPDATE_MONITOR) != 0) { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.START_MONITOR); + } else { + mEditableClient.requestCursorUpdates(SessionTextInput.EditableClient.END_MONITOR); + } + return true; + } + + @Override // SessionTextInput.EditableListener + public void onDefaultKeyEvent(final KeyEvent event) { + ThreadUtils.runOnUiThread( + 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 + final 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 (final InterruptedException e) { + } + } + return sBackgroundHandler; + } + + private synchronized boolean canReturnCustomHandler() { + if (mIMEState == IME_STATE_DISABLED) { + return false; + } + for (final StackTraceElement frame : Thread.currentThread().getStackTrace()) { + // We only return our custom Handler to InputMethodManager's InputConnection + // proxy. For all other purposes, we return the regular Handler. + // InputMethodManager retrieves the Handler for its InputConnection proxy + // inside its method startInputInner(), so we check for that here. This is + // valid from Android 2.2 to at least Android 4.2. If this situation ever + // changes, we gracefully fall back to using the regular Handler. + if ("startInputInner".equals(frame.getMethodName()) + && "android.view.inputmethod.InputMethodManager".equals(frame.getClassName())) { + // Only return our own Handler to InputMethodManager and only prior to 24. + return Build.VERSION.SDK_INT < 24; + } + if (CUSTOM_HANDLER_TEST_METHOD.equals(frame.getMethodName()) + && CUSTOM_HANDLER_TEST_CLASS.equals(frame.getClassName())) { + // InputConnection tests should also run on the custom handler + return true; + } + } + return false; + } + + private boolean isPhysicalKeyboardPresent() { + final View v = getView(); + if (v == null) { + return false; + } + final Configuration config = v.getContext().getResources().getConfiguration(); + return config.keyboard != Configuration.KEYBOARD_NOKEYS; + } + + @Override // InputConnection + public Handler getHandler() { + final Handler handler; + if (isPhysicalKeyboardPresent()) { + handler = ThreadUtils.getUiHandler(); + } else { + handler = getBackgroundHandler(); + } + return mEditableClient.setInputConnectionHandler(handler); + } + + @Override // SessionTextInput.InputConnectionClient + public Handler getHandler(final Handler defHandler) { + if (!canReturnCustomHandler()) { + return defHandler; + } + + return getHandler(); + } + + @Override // InputConnection + public void closeConnection() { + if (mBatchEditCount != 0) { + // GBoard may call this into batch edit mode then it doesn't call endBatchEdit. + // Since we are recycle GeckoInputConnection, we have to reset + // batch count even if IME/keyboard bug. + if (DEBUG) { + Log.d(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); + } + mBatchEditCount = 0; + // setBatchMode will call onTextChange and/or onSelectionChange for us. + mEditableClient.setBatchMode(false); + } + super.closeConnection(); + } + + @Override // SessionTextInput.InputConnectionClient + public synchronized InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + if (mIMEState == IME_STATE_DISABLED) { + return null; + } + + final Context context = getView().getContext(); + final 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)); + } + + final String prevInputMethod = mCurrentInputMethod; + mCurrentInputMethod = InputMethods.getCurrentInputMethod(context); + if (DEBUG) { + Log.d(LOGTAG, "IME: CurrentInputMethod=" + mCurrentInputMethod); + } + + outAttrs.initialSelStart = mLastSelectionStart; + outAttrs.initialSelEnd = mLastSelectionEnd; + return this; + } + + private boolean replaceComposingSpanWithSelection() { + final Editable content = getEditable(); + if (content == null) { + return false; + } + final int a = getComposingSpanStart(content); + final int 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(final CharSequence text, final 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(final int start, final int end) { + if (start < 0 || end < 0) { + // Some keyboards (e.g. Samsung) can call setSelection with + // negative offsets. In that case we ignore the call, similar to how + // BaseInputConnection.setSelection ignores offsets that go past the length. + return true; + } + return super.setSelection(start, end); + } + + @Override + public boolean sendKeyEvent(final @NonNull KeyEvent event) { + final KeyEvent translatedEvent = translateKey(event.getKeyCode(), event); + mEditableClient.sendKeyEvent(getView(), event.getAction(), translatedEvent); + return false; // seems to always return false + } + + private KeyEvent translateKey(final int keyCode, final @NonNull KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_ENTER: + if ((event.getFlags() & KeyEvent.FLAG_EDITOR_ACTION) != 0 + && mIMEActionHint.equals("maybenext")) { + // XXX It is not good to dispatch tab key for web compatibility. + // See https://github.com/w3c/uievents/issues/253 and bug 1600540. + return new KeyEvent( + event.getDownTime(), + event.getEventTime(), + event.getAction(), + KeyEvent.KEYCODE_TAB, + 0); + } + break; + } + return event; + } + + // Called by OnDefaultKeyEvent handler, up from Gecko + /* package */ void performDefaultKeyAction(final KeyEvent event) { + switch (event.getKeyCode()) { + case KeyEvent.KEYCODE_MUTE: + case KeyEvent.KEYCODE_HEADSETHOOK: + case KeyEvent.KEYCODE_MEDIA_PLAY: + case KeyEvent.KEYCODE_MEDIA_PAUSE: + case KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE: + case KeyEvent.KEYCODE_MEDIA_STOP: + case KeyEvent.KEYCODE_MEDIA_NEXT: + case KeyEvent.KEYCODE_MEDIA_PREVIOUS: + case KeyEvent.KEYCODE_MEDIA_REWIND: + case KeyEvent.KEYCODE_MEDIA_RECORD: + case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: + case KeyEvent.KEYCODE_MEDIA_CLOSE: + case KeyEvent.KEYCODE_MEDIA_EJECT: + case KeyEvent.KEYCODE_MEDIA_AUDIO_TRACK: + // Forward media keypresses to the registered handler so headset controls work + // Does the same thing as Chromium + // https://chromium.googlesource.com/chromium/src/+/49.0.2623.67/chrome/android/java/src/org/chromium/chrome/browser/tab/TabWebContentsDelegateAndroid.java#445 + // These are all the keys dispatchMediaKeyEvent supports. + if (Build.VERSION.SDK_INT >= 19) { + // dispatchMediaKeyEvent is only available on Android 4.4+ + final Context viewContext = getView().getContext(); + final AudioManager am = + (AudioManager) viewContext.getSystemService(Context.AUDIO_SERVICE); + am.dispatchMediaKeyEvent(event); + } + break; + } + } + + @TargetApi(Build.VERSION_CODES.N_MR1) + @Override + public boolean commitContent( + final InputContentInfo inputContentInfo, final int flags, final Bundle opts) { + final boolean requestPermission = + ((flags & InputConnection.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0); + if (requestPermission) { + try { + inputContentInfo.requestPermission(); + } catch (final Exception e) { + Log.e(LOGTAG, "InputContentInfo.requestPermission() failed.", e); + return false; + } + } + + try (final InputStream inputStream = + getView() + .getContext() + .getContentResolver() + .openInputStream(inputContentInfo.getContentUri()); + final ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + final byte[] data = new byte[4096]; + int readed; + while ((readed = inputStream.read(data)) != -1) { + outputStream.write(data, 0, readed); + } + mEditableClient.insertImage( + outputStream.toByteArray(), inputContentInfo.getDescription().getMimeType(0)); + } catch (final FileNotFoundException e) { + Log.e(LOGTAG, "Cannot open provider URI.", e); + return false; + } catch (final IOException e) { + Log.e(LOGTAG, "Cannot read/write provider URI.", e); + return false; + } finally { + if (requestPermission) { + inputContentInfo.releasePermission(); + } + } + + return true; + } + + @Override // SessionTextInput.EditableListener + public void notifyIME(final @IMENotificationType int type) { + switch (type) { + case NOTIFY_IME_OF_FOCUS: + // Showing/hiding vkb is done in notifyIMEContext + if (mBatchEditCount != 0) { + Log.w(LOGTAG, "resetting with mBatchEditCount = " + mBatchEditCount); + mBatchEditCount = 0; + } + break; + + case NOTIFY_IME_OF_BLUR: + break; + + case NOTIFY_IME_OF_TOKEN: + case NOTIFY_IME_OPEN_VKB: + case NOTIFY_IME_REPLY_EVENT: + case NOTIFY_IME_TO_CANCEL_COMPOSITION: + case NOTIFY_IME_TO_COMMIT_COMPOSITION: + default: + if (DEBUG) { + throw new IllegalArgumentException("Unexpected NOTIFY_IME=" + type); + } + break; + } + } + + @Override // SessionTextInput.EditableListener + public synchronized void notifyIMEContext( + @IMEState final int state, + final String typeHint, + final String modeHint, + final String actionHint, + @IMEContextFlags final int flags) { + // mIMEState and the mIME*Hint fields should only be changed by notifyIMEContext, + // and not reset anywhere else. Usually, notifyIMEContext is called right after a + // focus or blur, so resetting mIMEState during the focus or blur seems harmless. + // However, this behavior is not guaranteed. Gecko may call notifyIMEContext + // independent of focus change; that is, a focus change may not be accompanied by + // a notifyIMEContext call. So if we reset mIMEState inside focus, there may not + // be another notifyIMEContext call to set mIMEState to a proper value (bug 829318) + /* When IME is 'disabled', IME processing is disabled. + In addition, the IME UI is hidden */ + mIMEState = state; + mIMEActionHint = (actionHint == null) ? "" : actionHint; + + // These fields are reset here and will be updated when restartInput is called below + mUpdateRequest = null; + mCurrentInputMethod = ""; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java new file mode 100644 index 0000000000..2fe7c8ead0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoInputStream.java @@ -0,0 +1,205 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.util.LinkedList; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** + * This class provides an {@link InputStream} wrapper for a Gecko nsIChannel (or really, + * nsIRequest). + */ +@WrapForJNI +@AnyThread +/* package */ class GeckoInputStream extends InputStream { + private static final String LOGTAG = "GeckoInputStream"; + + private LinkedList<ByteBuffer> mBuffers = new LinkedList<>(); + private boolean mEOF; + private boolean mClosed; + private boolean mHaveError; + private long mReadTimeout; + private boolean mResumed; + private Support mSupport; + + /** + * This is only called via JNI. The support instance provides callbacks for the native + * counterpart. + * + * @param support An instance of {@link Support}, used for native callbacks. + */ + /* package */ GeckoInputStream(final @Nullable Support support) { + mSupport = support; + } + + public void setReadTimeoutMillis(final long millis) { + mReadTimeout = millis; + } + + @Override + public synchronized void close() throws IOException { + super.close(); + mClosed = true; + + if (mSupport != null) { + mSupport.close(); + mSupport = null; + } + } + + @Override + public synchronized int available() throws IOException { + if (mClosed) { + return 0; + } + + final ByteBuffer buf = mBuffers.peekFirst(); + return buf != null ? buf.remaining() : 0; + } + + private void ensureNotClosed() throws IOException { + if (mClosed) { + throw new IOException("Stream is closed"); + } + } + + @Override + public synchronized int read() throws IOException { + ensureNotClosed(); + + final int expect = Integer.SIZE / 8; + final byte[] bytes = new byte[expect]; + + int count = 0; + while (count < expect) { + final long bytesRead = read(bytes, count, expect - count); + if (bytesRead < 0) { + return -1; + } + + count += bytesRead; + } + + final ByteBuffer buffer = ByteBuffer.wrap(bytes); + return buffer.getInt(); + } + + @Override + public int read(final @NonNull byte[] b) throws IOException { + return read(b, 0, b.length); + } + + @Override + public synchronized int read(final @NonNull byte[] dest, final int offset, final int length) + throws IOException { + ensureNotClosed(); + + final long startTime = System.currentTimeMillis(); + while (!mEOF && mBuffers.size() == 0) { + if (mReadTimeout > 0 && (System.currentTimeMillis() - startTime) >= mReadTimeout) { + throw new IOException("Timed out"); + } + + // The underlying channel is suspended, so resume that before + // waiting for a buffer. + if (!mResumed) { + if (mSupport != null) { + mSupport.resume(); + } + mResumed = true; + } + + try { + wait(mReadTimeout); + } catch (final InterruptedException e) { + } + } + + if (mEOF && mBuffers.size() == 0) { + if (mHaveError) { + throw new IOException("Unknown error"); + } + + // We have no data and we're not expecting more. + return -1; + } + + final ByteBuffer buf = mBuffers.peekFirst(); + final int readCount = Math.min(length, buf.remaining()); + buf.get(dest, offset, readCount); + + if (buf.remaining() == 0) { + // We're done with this buffer, advance the queue. + mBuffers.removeFirst(); + } + + return readCount; + } + + /** Called by native code to indicate that no more data will be sent via {@link #appendBuffer}. */ + @WrapForJNI(calledFrom = "gecko") + public synchronized void sendEof() { + if (mEOF) { + throw new IllegalStateException("Already have EOF"); + } + + mEOF = true; + notifyAll(); + } + + /** Called by native code to indicate that there was an error while reading the stream. */ + @WrapForJNI(calledFrom = "gecko") + public synchronized void sendError() { + if (mEOF) { + throw new IllegalStateException("Already have EOF"); + } + + mEOF = true; + mHaveError = true; + notifyAll(); + } + + /** + * Called by native code to provide data for this stream. + * + * @param buf the bytes + * @throws IOException + */ + @WrapForJNI(exceptionMode = "nsresult", calledFrom = "gecko") + /* package */ synchronized void appendBuffer(final byte[] buf) throws IOException { + + if (mClosed) { + throw new IllegalStateException("Stream is closed"); + } + + if (mEOF) { + throw new IllegalStateException("EOF, no more data expected"); + } + + mBuffers.add(ByteBuffer.wrap(buf)); + notifyAll(); + } + + @WrapForJNI + private static class Support extends JNIObject { + @WrapForJNI(dispatchTo = "gecko") + private native void resume(); + + @WrapForJNI(dispatchTo = "gecko") + private native void close(); + + @Override // JNIObject + protected void disposeNative() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java new file mode 100644 index 0000000000..c991913b75 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoResult.java @@ -0,0 +1,1072 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.SimpleArrayMap; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.ListIterator; +import java.util.concurrent.CancellationException; +import java.util.concurrent.TimeoutException; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.IXPCOMEventTarget; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.gecko.util.XPCOMEventTarget; + +/** + * GeckoResult is a class that represents an asynchronous result. The result is initially pending, + * and at a later time, the result may be completed with {@link #complete a value} or {@link + * #completeExceptionally an exception} depending on the outcome of the asynchronous operation. For + * example, + * + * <pre> + * public GeckoResult<Integer> divide(final int dividend, final int divisor) { + * final GeckoResult<Integer> result = new GeckoResult<>(); + * (new Thread(() -> { + * if (divisor != 0) { + * result.complete(dividend / divisor); + * } else { + * result.completeExceptionally(new ArithmeticException("Dividing by zero")); + * } + * })).start(); + * return result; + * }</pre> + * + * <p>To retrieve the completed value or exception, use one of the {@link #then} methods to register + * listeners on the result. Listeners are run on the thread where the GeckoResult is created if a + * {@link Looper} is present. For example, to retrieve a completed value, + * + * <pre> + * divide(42, 2).then(new GeckoResult.OnValueListener<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // value == 21 + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // Not called + * } + * });</pre> + * + * <p>And to retrieve a completed exception, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) { + * // Not called + * } + * }, new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // exception instanceof ArithmeticException + * } + * });</pre> + * + * <p>{@link #then} calls may be chained to complete multiple asynchonous operations in sequence. + * This example takes an integer, converts it to a String, and appends it to another String, + * + * <pre> + * divide(42, 2).then(new GeckoResult.OnValueListener<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * return GeckoResult.fromValue(value.toString()); + * } + * }).then(new GeckoResult.OnValueListener<String, String>() { + * @Override + * public GeckoResult<String> onValue(final String value) { + * return GeckoResult.fromValue("42 / 2 = " + value); + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == "42 / 2 = 21" + * return null; + * } + * });</pre> + * + * <p>Chaining works with exception listeners as well. For example, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnExceptionListener<String>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * return "foo"; + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == "foo" + * } + * });</pre> + * + * <p>A completed value/exception will propagate down the chain even if an intermediate step does + * not have a value/exception listener. For example, + * + * <pre> + * divide(42, 0).then(new GeckoResult.OnValueListener<Integer, String>() { + * @Override + * public GeckoResult<String> onValue(final Integer value) { + * // Not called + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) { + * // exception instanceof ArithmeticException + * } + * });</pre> + * + * <p>However, any propagated value will be coerced to null. For example, + * + * <pre> + * divide(42, 2).then(new GeckoResult.OnExceptionListener<String>() { + * @Override + * public GeckoResult<String> onException(final Throwable exception) { + * // Not called + * } + * }).then(new GeckoResult.OnValueListener<String, Void>() { + * @Override + * public GeckoResult<Void> onValue(final String value) { + * // value == null + * } + * });</pre> + * + * <p>If a GeckoResult is created on a thread without a {@link Looper}, {@link + * #then(OnValueListener, OnExceptionListener)} is unusable (and will throw {@link + * IllegalThreadStateException}). In this scenario, the value is only available via {@link + * #poll(long)}. Alternatively, you may also chain the GeckoResult to one with a {@link Handler} via + * {@link #withHandler(Handler)}. You may then use {@link #then(OnValueListener, + * OnExceptionListener)} on the returned GeckoResult normally. + * + * <p>Any exception thrown by a listener are automatically used to complete the result. At the end + * of every chain, there is an implicit exception listener that rethrows any uncaught and unhandled + * exception as {@link UncaughtException}. The following example will cause {@link + * UncaughtException} to be thrown because {@code BazException} is uncaught and unhandled at the end + * of the chain, + * + * <pre> + * GeckoResult.fromValue(42).then(new GeckoResult.OnValueListener<Integer, Void>() { + * @Override + * public GeckoResult<Void> onValue(final Integer value) throws FooException { + * throw new FooException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) throws Exception { + * // exception instanceof FooException + * throw new BarException(); + * } + * }).then(new GeckoResult.OnExceptionListener<Void>() { + * @Override + * public GeckoResult<Void> onException(final Throwable exception) throws Throwable { + * // exception instanceof BarException + * return new BazException(); + * } + * });</pre> + * + * @param <T> The type of the value delivered via the GeckoResult. + */ +@AnyThread +public class GeckoResult<T> { + private static final String LOGTAG = "GeckoResult"; + + private interface Dispatcher { + void dispatch(Runnable r); + } + + private static class HandlerDispatcher implements Dispatcher { + HandlerDispatcher(final Handler h) { + mHandler = h; + } + + public void dispatch(final Runnable r) { + mHandler.post(r); + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof HandlerDispatcher)) { + return false; + } + return mHandler.equals(((HandlerDispatcher) other).mHandler); + } + + @Override + public int hashCode() { + return mHandler.hashCode(); + } + + Handler mHandler; + } + + private static class XPCOMEventTargetDispatcher implements Dispatcher { + private IXPCOMEventTarget mEventTarget; + + public XPCOMEventTargetDispatcher(final IXPCOMEventTarget eventTarget) { + mEventTarget = eventTarget; + } + + @Override + public void dispatch(final Runnable r) { + mEventTarget.execute(r); + } + } + + private static class DirectDispatcher implements Dispatcher { + public void dispatch(final Runnable r) { + r.run(); + } + + static DirectDispatcher sInstance = new DirectDispatcher(); + + private DirectDispatcher() {} + } + + public static final class UncaughtException extends RuntimeException { + @SuppressWarnings("checkstyle:javadocmethod") + public UncaughtException(final Throwable cause) { + super(cause); + } + } + + /** Interface used to delegate cancellation operations for a {@link GeckoResult}. */ + @AnyThread + public interface CancellationDelegate { + + /** + * This method should attempt to cancel the in-progress operation for the result to which this + * instance was attached. See {@link GeckoResult#cancel()} for more details. + * + * @return A {@link GeckoResult} resolving to "true" if cancellation was successful, "false" + * otherwise. + */ + default @NonNull GeckoResult<Boolean> cancel() { + return GeckoResult.fromValue(false); + } + } + + /** + * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#DENY} + */ + @AnyThread + @NonNull + public static GeckoResult<AllowOrDeny> deny() { + return GeckoResult.fromValue(AllowOrDeny.DENY); + } + + /** + * @return a {@link GeckoResult} that resolves to {@link AllowOrDeny#ALLOW} + */ + @AnyThread + @NonNull + public static GeckoResult<AllowOrDeny> allow() { + return GeckoResult.fromValue(AllowOrDeny.ALLOW); + } + + // The default dispatcher for listeners on this GeckoResult. Other dispatchers can be specified + // when the listener is registered. + private final Dispatcher mDispatcher; + private boolean mComplete; + private T mValue; + private Throwable mError; + private boolean mIsUncaughtError; + private SimpleArrayMap<Dispatcher, ArrayList<Runnable>> mListeners = new SimpleArrayMap<>(); + + private GeckoResult<?> mParent; + private CancellationDelegate mCancellationDelegate; + + /** + * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link + * #completeExceptionally(Throwable)} in order to fulfill the result. + */ + @WrapForJNI + public GeckoResult() { + if (ThreadUtils.isOnUiThread()) { + mDispatcher = new HandlerDispatcher(ThreadUtils.getUiHandler()); + } else if (Looper.myLooper() != null) { + mDispatcher = new HandlerDispatcher(new Handler()); + } else if (XPCOMEventTarget.launcherThread().isOnCurrentThread()) { + mDispatcher = new XPCOMEventTargetDispatcher(XPCOMEventTarget.launcherThread()); + } else { + mDispatcher = null; + } + } + + /** + * Construct an incomplete GeckoResult. Call {@link #complete(Object)} or {@link + * #completeExceptionally(Throwable)} in order to fulfill the result. + * + * @param handler This {@link Handler} will be used for dispatching listeners registered via + * {@link #then(OnValueListener, OnExceptionListener)}. + */ + public GeckoResult(final Handler handler) { + mDispatcher = new HandlerDispatcher(handler); + } + + /** + * This constructs a result that is chained to the specified result. + * + * @param from The {@link GeckoResult} to copy. + */ + public GeckoResult(final GeckoResult<T> from) { + this(); + completeFrom(from); + } + + /** + * Construct a result that is completed with the specified value. + * + * @param value The value used to complete the newly created result. + * @param <U> Type for the result. + * @return The completed {@link GeckoResult} + */ + @WrapForJNI + public static @NonNull <U> GeckoResult<U> fromValue(@Nullable final U value) { + final GeckoResult<U> result = new GeckoResult<>(); + result.complete(value); + return result; + } + + /** + * Construct a result that is completed with the specified {@link Throwable}. May not be null. + * + * @param error The exception used to complete the newly created result. + * @param <T> Type for the result if the result had been completed without exception. + * @return The completed {@link GeckoResult} + */ + @WrapForJNI + public static @NonNull <T> GeckoResult<T> fromException(@NonNull final Throwable error) { + final GeckoResult<T> result = new GeckoResult<>(); + result.completeExceptionally(error); + return result; + } + + @Override + public synchronized int hashCode() { + return Arrays.hashCode(new Object[] {mComplete, mValue, mError}); + } + + // This can go away once we can rely on java.util.Objects.equals() (API 19) + private static boolean objectEquals(final Object a, final Object b) { + return a == b || (a != null && a.equals(b)); + } + + @Override + public synchronized boolean equals(final Object other) { + if (other instanceof GeckoResult<?>) { + final GeckoResult<?> result = (GeckoResult<?>) other; + return result.mComplete == mComplete + && objectEquals(result.mError, mError) + && objectEquals(result.mValue, mValue); + } + + return false; + } + + /** + * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}. + * + * @param valueListener An instance of {@link OnValueListener}, called when the {@link + * GeckoResult} is completed with a value. + * @param <U> Type of the new result that is returned by the listener. + * @return A new {@link GeckoResult} that the listener will complete. + */ + public @NonNull <U> GeckoResult<U> then(@NonNull final OnValueListener<T, U> valueListener) { + return then(valueListener, null); + } + + /** + * Convenience method for {@link #map(OnValueMapper, OnExceptionMapper)}. + * + * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is + * completed with a value. + * @param <U> Type of the new value that is returned by the mapper. + * @return A new {@link GeckoResult} that will contain the mapped value. + */ + public @NonNull <U> GeckoResult<U> map(@Nullable final OnValueMapper<T, U> valueMapper) { + return map(valueMapper, null); + } + + /** + * Transform the value and error of this {@link GeckoResult}. + * + * @param valueMapper An instance of {@link OnValueMapper}, called when the {@link GeckoResult} is + * completed with a value. + * @param exceptionMapper An instance of {@link OnExceptionMapper}, called when the {@link + * GeckoResult} is completed with an exception. + * @param <U> Type of the new value that is returned by the mapper. + * @return A new {@link GeckoResult} that will contain the mapped value. + */ + public @NonNull <U> GeckoResult<U> map( + @Nullable final OnValueMapper<T, U> valueMapper, + @Nullable final OnExceptionMapper exceptionMapper) { + final OnValueListener<T, U> valueListener = + valueMapper != null ? value -> GeckoResult.fromValue(valueMapper.onValue(value)) : null; + final OnExceptionListener<U> exceptionListener = + exceptionMapper != null + ? error -> GeckoResult.fromException(exceptionMapper.onException(error)) + : null; + return then(valueListener, exceptionListener); + } + + /** + * Convenience method for {@link #then(OnValueListener, OnExceptionListener)}. + * + * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link + * GeckoResult} is completed with an {@link Exception}. + * @param <U> Type of the new result that is returned by the listener. + * @return A new {@link GeckoResult} that the listener will complete. + */ + public @NonNull <U> GeckoResult<U> exceptionally( + @NonNull final OnExceptionListener<U> exceptionListener) { + return then(null, exceptionListener); + } + + /** + * Replacement for {@link java.util.function.Consumer} for devices with minApi < 24. + * + * @param <T> the type of the input for this consumer. + */ + // TODO: Remove this when we move to min API 24 + public interface Consumer<T> { + /** + * Run this consumer for the given input. + * + * @param t the input value. + */ + @AnyThread + void accept(@Nullable T t); + } + + /** + * Convenience method for {@link #accept(Consumer, Consumer)}. + * + * @param valueListener An instance of {@link Consumer}, called when the {@link GeckoResult} is + * completed with a value. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull GeckoResult<Void> accept(@Nullable final Consumer<T> valueListener) { + return accept(valueListener, null); + } + + /** + * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or + * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link + * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}. + * + * <p>If the result is already complete when this method is called, listeners will be invoked in a + * future {@link Looper} iteration. + * + * @param valueConsumer An instance of {@link Consumer}, called when the {@link GeckoResult} is + * completed with a value. + * @param exceptionConsumer An instance of {@link Consumer}, called when the {@link GeckoResult} + * is completed with an {@link Throwable}. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull GeckoResult<Void> accept( + @Nullable final Consumer<T> valueConsumer, + @Nullable final Consumer<Throwable> exceptionConsumer) { + final OnValueListener<T, Void> valueListener = + valueConsumer == null + ? null + : value -> { + valueConsumer.accept(value); + return null; + }; + + final OnExceptionListener<Void> exceptionListener = + exceptionConsumer == null + ? null + : value -> { + exceptionConsumer.accept(value); + return null; + }; + + return then(valueListener, exceptionListener); + } + + /** + * Adds listeners to be called when the {@link GeckoResult} is completed regardless of success + * status. Listeners will be invoked on the {@link Looper} returned from {@link #getLooper()}. If + * null, this method will throw {@link IllegalThreadStateException}. + * + * <p>If the result is already complete when this method is called, listeners will be invoked in a + * future {@link Looper} iteration. + * + * @param finallyRunnable An instance of {@link Runnable}, called when the {@link GeckoResult} is + * completed with a value or a {@link Throwable}. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull GeckoResult<Void> finally_(@NonNull final Runnable finallyRunnable) { + final OnValueListener<T, Void> valueListener = + value -> { + finallyRunnable.run(); + return null; + }; + final OnExceptionListener<Void> exceptionListener = + value -> { + finallyRunnable.run(); + return null; + }; + return then(valueListener, exceptionListener); + } + + /* package */ @NonNull + GeckoResult<Void> getOrAccept(@Nullable final Consumer<T> valueConsumer) { + return getOrAccept(valueConsumer, null); + } + + /* package */ @NonNull + GeckoResult<Void> getOrAccept( + @Nullable final Consumer<T> valueConsumer, + @Nullable final Consumer<Throwable> exceptionConsumer) { + if (haveValue() && valueConsumer != null) { + valueConsumer.accept(mValue); + return GeckoResult.fromValue(null); + } + + if (haveError() && exceptionConsumer != null) { + exceptionConsumer.accept(mError); + return GeckoResult.fromValue(null); + } + + return accept(valueConsumer, exceptionConsumer); + } + + /** + * Adds listeners to be called when the {@link GeckoResult} is completed either with a value or + * {@link Throwable}. Listeners will be invoked on the {@link Looper} returned from {@link + * #getLooper()}. If null, this method will throw {@link IllegalThreadStateException}. + * + * <p>If the result is already complete when this method is called, listeners will be invoked in a + * future {@link Looper} iteration. + * + * @param valueListener An instance of {@link OnValueListener}, called when the {@link + * GeckoResult} is completed with a value. + * @param exceptionListener An instance of {@link OnExceptionListener}, called when the {@link + * GeckoResult} is completed with an {@link Throwable}. + * @param <U> Type of the new result that is returned by the listeners. + * @return A new {@link GeckoResult} that the listeners will complete. + */ + public @NonNull <U> GeckoResult<U> then( + @Nullable final OnValueListener<T, U> valueListener, + @Nullable final OnExceptionListener<U> exceptionListener) { + if (mDispatcher == null) { + throw new IllegalThreadStateException("Must have a Handler"); + } + + return thenInternal(mDispatcher, valueListener, exceptionListener); + } + + private @NonNull <U> GeckoResult<U> thenInternal( + @NonNull final Dispatcher dispatcher, + @Nullable final OnValueListener<T, U> valueListener, + @Nullable final OnExceptionListener<U> exceptionListener) { + if (valueListener == null && exceptionListener == null) { + throw new IllegalArgumentException("At least one listener should be non-null"); + } + + final GeckoResult<U> result = new GeckoResult<U>(); + result.mParent = this; + thenInternal( + dispatcher, + () -> { + try { + if (haveValue()) { + result.completeFrom(valueListener != null ? valueListener.onValue(mValue) : null); + } else if (!haveError()) { + // Listener called without completion? + throw new AssertionError(); + } else if (exceptionListener != null) { + result.completeFrom(exceptionListener.onException(mError)); + } else { + result.mIsUncaughtError = mIsUncaughtError; + result.completeExceptionally(mError); + } + } catch (final Throwable e) { + if (!result.mComplete) { + result.mIsUncaughtError = true; + result.completeExceptionally(e); + } else if (e instanceof RuntimeException) { + // This should only be UncaughtException, but we rethrow all RuntimeExceptions + // to avoid squelching logic errors in GeckoResult itself. + throw (RuntimeException) e; + } + } + }); + return result; + } + + private synchronized void thenInternal( + @NonNull final Dispatcher dispatcher, @NonNull final Runnable listener) { + if (mComplete) { + dispatcher.dispatch(listener); + } else { + if (!mListeners.containsKey(dispatcher)) { + mListeners.put(dispatcher, new ArrayList<>(1)); + } + mListeners.get(dispatcher).add(listener); + } + } + + @WrapForJNI + private void nativeThen( + @NonNull final GeckoCallback accept, @NonNull final GeckoCallback reject) { + // NB: We could use the lambda syntax here, but given all the layers + // of abstraction it's helpful to see the types written explicitly. + thenInternal( + DirectDispatcher.sInstance, + new OnValueListener<T, Void>() { + @Override + public GeckoResult<Void> onValue(final T value) { + accept.call(value); + return null; + } + }, + new OnExceptionListener<Void>() { + @Override + public GeckoResult<Void> onException(final Throwable exception) { + reject.call(exception); + return null; + } + }); + } + + /** + * @return Get the {@link Looper} that will be used to schedule listeners registered via {@link + * #then(OnValueListener, OnExceptionListener)}. + */ + public @Nullable Looper getLooper() { + if (mDispatcher == null || !(mDispatcher instanceof HandlerDispatcher)) { + return null; + } + + return ((HandlerDispatcher) mDispatcher).mHandler.getLooper(); + } + + /** + * Returns a new GeckoResult that will be completed by this instance. Listeners registered via + * {@link #then(OnValueListener, OnExceptionListener)} will be run on the specified {@link + * Handler}. + * + * @param handler A {@link Handler} where listeners will be run. May be null. + * @return A new GeckoResult. + */ + public @NonNull GeckoResult<T> withHandler(final @Nullable Handler handler) { + final GeckoResult<T> result = new GeckoResult<>(handler); + result.completeFrom(this); + return result; + } + + /** + * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances + * are complete. + * + * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The + * list is guaranteed to be in the same order as the inputs. + * + * <p>If any of the {@link GeckoResult} fails, the returned result will fail. + * + * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value + * <code>null</code>. + * + * @param pending the input {@link GeckoResult}s. + * @param <V> type of the {@link GeckoResult}'s values. + * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when + * at least one of the inputs fail. + */ + @SuppressWarnings("varargs") + @SafeVarargs + @NonNull + public static <V> GeckoResult<List<V>> allOf(final @NonNull GeckoResult<V>... pending) { + return allOf(Arrays.asList(pending)); + } + + /** + * Returns a {@link GeckoResult} that is completed when the given {@link GeckoResult} instances + * are complete. + * + * <p>The returned {@link GeckoResult} will resolve with the list of values from the inputs. The + * list is guaranteed to be in the same order as the inputs. + * + * <p>If any of the {@link GeckoResult} fails, the returned result will fail. + * + * <p>If no inputs are provided, the returned {@link GeckoResult} will complete with the value + * <code>null</code>. + * + * @param pending the input {@link GeckoResult}s. + * @param <V> type of the {@link GeckoResult}'s values. + * @return a {@link GeckoResult} that will complete when all of the inputs are completed or when + * at least one of the inputs fail. + */ + @NonNull + public static <V> GeckoResult<List<V>> allOf(final @Nullable List<GeckoResult<V>> pending) { + if (pending == null) { + return GeckoResult.fromValue(null); + } + + return new AllOfResult<>(pending); + } + + private static class AllOfResult<V> extends GeckoResult<List<V>> { + private boolean mFailed = false; + private int mResultCount = 0; + private final List<V> mAccumulator; + private final List<GeckoResult<V>> mPending; + + public AllOfResult(final @NonNull List<GeckoResult<V>> pending) { + // Initialize the list with nulls so we can fill it in the same order as the input list + mAccumulator = new ArrayList<>(Collections.nCopies(pending.size(), null)); + mPending = pending; + + // If the input list is empty, there's nothing to do + if (pending.size() == 0) { + complete(mAccumulator); + return; + } + + // We use iterators so we can access the index and preserve the list order + final ListIterator<GeckoResult<V>> it = pending.listIterator(); + while (it.hasNext()) { + final int index = it.nextIndex(); + it.next().accept(value -> onResult(value, index), this::onError); + } + } + + private void onResult(final V value, final int index) { + if (mFailed) { + // Some other element in the list already failed, nothing to do here + return; + } + + mResultCount++; + mAccumulator.set(index, value); + + if (mResultCount == mPending.size()) { + complete(mAccumulator); + } + } + + private void onError(final Throwable error) { + mFailed = true; + completeExceptionally(error); + } + } + + private void dispatchLocked() { + if (!mComplete) { + throw new IllegalStateException("Cannot dispatch unless result is complete"); + } + + if (mListeners.isEmpty()) { + if (mIsUncaughtError) { + // We have no listeners to forward the uncaught exception to; + // rethrow the exception to make it visible. + throw new UncaughtException(mError); + } + return; + } + + if (mDispatcher == null) { + throw new AssertionError("Shouldn't have listeners with null dispatcher"); + } + + for (int i = 0; i < mListeners.size(); ++i) { + final Dispatcher dispatcher = mListeners.keyAt(i); + final ArrayList<Runnable> jobs = mListeners.valueAt(i); + dispatcher.dispatch( + () -> { + for (final Runnable job : jobs) { + job.run(); + } + }); + } + mListeners.clear(); + } + + /** + * Completes this result based on another result. + * + * @param other The result that this result should mirror + */ + public void completeFrom(final @Nullable GeckoResult<T> other) { + if (other == null) { + complete(null); + return; + } + + this.mCancellationDelegate = other.mCancellationDelegate; + other.thenInternal( + DirectDispatcher.sInstance, + () -> { + if (other.haveValue()) { + complete(other.mValue); + } else { + mIsUncaughtError = other.mIsUncaughtError; + completeExceptionally(other.mError); + } + }); + } + + /** + * Return the value of this result, waiting for it to be completed if necessary. If the result is + * completed with an exception it will be rethrown here. + * + * <p>You must not call this method if the current thread has a {@link Looper} due to the + * possibility of a deadlock. If this occurs, {@link IllegalStateException} is thrown. + * + * @return The value of this result. + * @throws Throwable The {@link Throwable} contained in this result, if any. + * @throws IllegalThreadStateException if this method is called on a thread that has a {@link + * Looper}. + */ + public synchronized @Nullable T poll() throws Throwable { + if (Looper.myLooper() != null) { + throw new IllegalThreadStateException("Cannot poll indefinitely from thread with Looper"); + } + + return poll(Long.MAX_VALUE); + } + + /** + * Return the value of this result, waiting for it to be completed if necessary. If the result is + * completed with an exception it will be rethrown here. + * + * <p>Caution is advised if the caller is on a thread with a {@link Looper}, as it's possible to + * effectively deadlock in cases when the work is being completed on the calling thread. It's + * preferable to use {@link #then(OnValueListener, OnExceptionListener)} in such circumstances, + * but if you must use this method consider a small timeout value. + * + * @param timeoutMillis Number of milliseconds to wait for the result to complete. + * @return The value of this result. + * @throws Throwable The {@link Throwable} contained in this result, if any. + * @throws TimeoutException if we wait more than timeoutMillis before the result is completed. + */ + public synchronized @Nullable T poll(final long timeoutMillis) throws Throwable { + final long start = SystemClock.uptimeMillis(); + long remaining = timeoutMillis; + while (!mComplete && remaining > 0) { + try { + wait(remaining); + } catch (final InterruptedException e) { + } + + remaining = timeoutMillis - (SystemClock.uptimeMillis() - start); + } + + if (!mComplete) { + throw new TimeoutException(); + } + + if (haveError()) { + throw mError; + } + + return mValue; + } + + /** + * Complete the result with the specified value. IllegalStateException is thrown if the result is + * already complete. + * + * @param value The value used to complete the result. + * @throws IllegalStateException If the result is already completed. + */ + @WrapForJNI + public synchronized void complete(final @Nullable T value) { + if (mComplete) { + throw new IllegalStateException("result is already complete"); + } + + mValue = value; + mComplete = true; + + dispatchLocked(); + notifyAll(); + } + + /** + * Complete the result with the specified {@link Throwable}. IllegalStateException is thrown if + * the result is already complete. + * + * @param exception The {@link Throwable} used to complete the result. + * @throws IllegalStateException If the result is already completed. + */ + @WrapForJNI + public synchronized void completeExceptionally(@NonNull final Throwable exception) { + if (mComplete) { + throw new IllegalStateException("result is already complete"); + } + + if (exception == null) { + throw new IllegalArgumentException("Throwable must not be null"); + } + + mError = exception; + mComplete = true; + + dispatchLocked(); + notifyAll(); + } + + /** + * An interface used to deliver values to listeners of a {@link GeckoResult} + * + * @param <T> Type of the value delivered via {@link #onValue(Object)} + * @param <U> Type of the value for the result returned from {@link #onValue(Object)} + */ + public interface OnValueListener<T, U> { + /** + * Called when a {@link GeckoResult} is completed with a value. Will be called on the same + * thread where the GeckoResult was created or on the {@link Handler} provided via {@link + * #withHandler(Handler)}. + * + * @param value The value of the {@link GeckoResult} + * @return Result used to complete the next result in the chain. May be null. + * @throws Throwable Exception used to complete next result in the chain. + */ + @AnyThread + @Nullable + GeckoResult<U> onValue(@Nullable T value) throws Throwable; + } + + /** + * An interface used to map {@link GeckoResult} values. + * + * @param <T> Type of the value delivered via {@link #onValue} + * @param <U> Type of the new value returned by {@link #onValue} + */ + public interface OnValueMapper<T, U> { + /** + * Called when a {@link GeckoResult} is completed with a value. Will be called on the same + * thread where the GeckoResult was created or on the {@link Handler} provided via {@link + * #withHandler(Handler)}. + * + * @param value The value of the {@link GeckoResult} + * @return Value used to complete the next result in the chain. May be null. + * @throws Throwable Exception used to complete next result in the chain. + */ + @AnyThread + @Nullable + U onValue(@Nullable T value) throws Throwable; + } + + /** An interface used to map {@link GeckoResult} exceptions. */ + public interface OnExceptionMapper { + /** + * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same + * thread where the GeckoResult was created or on the {@link Handler} provided via {@link + * #withHandler(Handler)}. + * + * @param exception Exception that completed the result. + * @return Exception used to complete the next result in the chain. May be null. + * @throws Throwable Exception used to complete next result in the chain. + */ + @AnyThread + @Nullable + Throwable onException(@NonNull Throwable exception) throws Throwable; + } + + /** + * An interface used to deliver exceptions to listeners of a {@link GeckoResult} + * + * @param <V> Type of the vale for the result returned from {@link #onException(Throwable)} + */ + public interface OnExceptionListener<V> { + /** + * Called when a {@link GeckoResult} is completed with an exception. Will be called on the same + * thread where the GeckoResult was created or on the {@link Handler} provided via {@link + * #withHandler(Handler)}. + * + * @param exception Exception that completed the result. + * @return Result used to complete the next result in the chain. May be null. + * @throws Throwable Exception used to complete next result in the chain. + */ + @AnyThread + @Nullable + GeckoResult<V> onException(@NonNull Throwable exception) throws Throwable; + } + + @WrapForJNI + private static class GeckoCallback extends JNIObject { + private native void call(Object arg); + + @Override + protected native void disposeNative(); + } + + private boolean haveValue() { + return mComplete && mError == null; + } + + private boolean haveError() { + return mComplete && mError != null; + } + + /** + * Attempts to cancel the operation associated with this result. + * + * <p>If this result has a {@link CancellationDelegate} attached via {@link + * #setCancellationDelegate(CancellationDelegate)}, the return value will be the result of calling + * {@link CancellationDelegate#cancel()} on that instance. Otherwise, if this result is chained to + * another result (via return value from {@link OnValueListener}), we will walk up the chain until + * a CancellationDelegate is found and run it. If no CancellationDelegate is found, a result + * resolving to "false" will be returned. + * + * <p>If this result is already complete, the returned result will always resolve to false. + * + * <p>If the returned result resolves to true, this result will be completed with a {@link + * CancellationException}. + * + * @return A GeckoResult resolving to a boolean indicating success or failure of the cancellation + * attempt. + */ + public synchronized @NonNull GeckoResult<Boolean> cancel() { + if (haveValue() || haveError()) { + return GeckoResult.fromValue(false); + } + + if (mCancellationDelegate != null) { + return mCancellationDelegate + .cancel() + .then( + value -> { + if (value) { + try { + this.completeExceptionally(new CancellationException()); + } catch (final IllegalStateException e) { + // Can't really do anything about this. + } + } + return GeckoResult.fromValue(value); + }); + } + + if (mParent != null) { + return mParent.cancel(); + } + + return GeckoResult.fromValue(false); + } + + /** + * Sets the instance of {@link CancellationDelegate} that will be invoked by {@link #cancel()}. + * + * @param delegate an instance of CancellationDelegate. + */ + public void setCancellationDelegate(final @Nullable CancellationDelegate delegate) { + mCancellationDelegate = delegate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java new file mode 100644 index 0000000000..aa5f01da59 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntime.java @@ -0,0 +1,1050 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.pm.ServiceInfo; +import android.content.res.Configuration; +import android.os.Build; +import android.os.Bundle; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.Process; +import android.provider.Settings; +import android.text.format.DateFormat; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; +import androidx.annotation.UiThread; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.OnLifecycleEvent; +import androidx.lifecycle.ProcessLifecycleOwner; +import java.io.File; +import java.io.FileNotFoundException; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.List; +import java.util.Map; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoNetworkManager; +import org.mozilla.gecko.GeckoScreenChangeListener; +import org.mozilla.gecko.GeckoScreenOrientation; +import org.mozilla.gecko.GeckoScreenOrientation.ScreenOrientation; +import org.mozilla.gecko.GeckoSystemStateListener; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.process.MemoryController; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.DebugConfig; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public final class GeckoRuntime implements Parcelable { + private static final String LOGTAG = "GeckoRuntime"; + private static final boolean DEBUG = false; + + private static final String CONFIG_FILE_PATH_TEMPLATE = + "/data/local/tmp/%s-geckoview-config.yaml"; + + /** + * Intent action sent to the crash handler when a crash is encountered. + * + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + */ + public static final String ACTION_CRASHED = "org.mozilla.gecko.ACTION_CRASHED"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a String with the + * path to a Breakpad minidump file containing information about the crash. Several crash + * reporters are able to ingest this in a crash report, including <a + * href="https://sentry.io">Sentry</a> and Mozilla's <a + * href="https://wiki.mozilla.org/Socorro">Socorro</a>. <br> + * <br> + * Be aware, the minidump can contain personally identifiable information. Ensure you are obeying + * all applicable laws and policies before sending this to a remote server. + * + * @see GeckoRuntimeSettings.Builder#crashHandler(Class) + */ + public static final String EXTRA_MINIDUMP_PATH = "minidumpPath"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. It refers to a string with the + * path to a file containing extra metadata about the crash. The file contains key-value pairs in + * the form + * + * <pre>Key=Value</pre> + * + * Be aware, it may contain sensitive data such as the URI that was loaded at the time of the + * crash. + */ + public static final String EXTRA_EXTRAS_PATH = "extrasPath"; + + /** + * This is a key for extra data sent with {@link #ACTION_CRASHED}. The value is a String matching + * one of the `CRASHED_PROCESS_TYPE_*` constants, describing what type of process the crash + * occurred in. + * + * @see GeckoSession.ContentDelegate#onCrash(GeckoSession) + */ + public static final String EXTRA_CRASH_PROCESS_TYPE = "processType"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating the main application process was + * affected by the crash, which is therefore fatal. + */ + public static final String CRASHED_PROCESS_TYPE_MAIN = "MAIN"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a foreground child process, such as a + * content process, crashed. The application may be able to recover from this crash, but it was + * likely noticable to the user. + */ + public static final String CRASHED_PROCESS_TYPE_FOREGROUND_CHILD = "FOREGROUND_CHILD"; + + /** + * Value for {@link #EXTRA_CRASH_PROCESS_TYPE} indicating a background child process crashed. This + * should have been recovered from automatically, and will have had minimal impact to the user, if + * any. + */ + public static final String CRASHED_PROCESS_TYPE_BACKGROUND_CHILD = "BACKGROUND_CHILD"; + + private final MemoryController mMemoryController = new MemoryController(); + + @Retention(RetentionPolicy.SOURCE) + @StringDef( + value = { + CRASHED_PROCESS_TYPE_MAIN, + CRASHED_PROCESS_TYPE_FOREGROUND_CHILD, + CRASHED_PROCESS_TYPE_BACKGROUND_CHILD + }) + public @interface CrashedProcessType {} + + private final class LifecycleListener implements LifecycleObserver { + private boolean mPaused = false; + + public LifecycleListener() {} + + @OnLifecycleEvent(Lifecycle.Event.ON_CREATE) + void onCreate() { + Log.d(LOGTAG, "Lifecycle: onCreate"); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_START) + void onStart() { + Log.d(LOGTAG, "Lifecycle: onStart"); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) + void onResume() { + Log.d(LOGTAG, "Lifecycle: onResume"); + if (mPaused) { + // Do not trigger the first onResume event because it breaks nsAppShell::sPauseCount counter + // thresholds. + GeckoThread.onResume(); + } + mPaused = false; + // Can resume location services, checks if was in use before going to background + GeckoAppShell.resumeLocation(); + // Monitor network status and send change notifications to Gecko + // while active. + GeckoNetworkManager.getInstance().start(GeckoAppShell.getApplicationContext()); + + // Set settings that may have changed between last app opening + GeckoAppShell.setIs24HourFormat( + DateFormat.is24HourFormat(GeckoAppShell.getApplicationContext())); + } + + @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) + void onPause() { + Log.d(LOGTAG, "Lifecycle: onPause"); + mPaused = true; + // Pause listening for locations when in background + GeckoAppShell.pauseLocation(); + // Stop monitoring network status while inactive. + GeckoNetworkManager.getInstance().stop(); + GeckoThread.onPause(); + } + } + + private static GeckoRuntime sDefaultRuntime; + + /** + * Get the default runtime for the given context. This will create and initialize the runtime with + * the default settings. + * + * <p>Note: Only use this for session-less apps. For regular apps, use create() instead. + * + * @param context An application context for the default runtime. + * @return The (static) default runtime for the context. + */ + @UiThread + public static synchronized @NonNull GeckoRuntime getDefault(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + if (DEBUG) { + Log.d(LOGTAG, "getDefault"); + } + if (sDefaultRuntime == null) { + sDefaultRuntime = new GeckoRuntime(); + sDefaultRuntime.attachTo(context); + sDefaultRuntime.init(context, new GeckoRuntimeSettings()); + } + + return sDefaultRuntime; + } + + private static GeckoRuntime sRuntime; + private GeckoRuntimeSettings mSettings; + private Delegate mDelegate; + private ServiceWorkerDelegate mServiceWorkerDelegate; + private WebNotificationDelegate mNotificationDelegate; + private ActivityDelegate mActivityDelegate; + private OrientationController mOrientationController; + private StorageController mStorageController; + private final WebExtensionController mWebExtensionController; + private WebPushController mPushController; + private final ContentBlockingController mContentBlockingController; + private final Autocomplete.StorageProxy mAutocompleteStorageProxy; + private final ProfilerController mProfilerController; + private final GeckoScreenChangeListener mScreenChangeListener; + + private GeckoRuntime() { + mWebExtensionController = new WebExtensionController(this); + mContentBlockingController = new ContentBlockingController(); + mAutocompleteStorageProxy = new Autocomplete.StorageProxy(); + mProfilerController = new ProfilerController(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + mScreenChangeListener = new GeckoScreenChangeListener(); + } else { + mScreenChangeListener = null; + } + + if (sRuntime != null) { + throw new IllegalStateException("Only one GeckoRuntime instance is allowed"); + } + sRuntime = this; + } + + @WrapForJNI + @UiThread + /* package */ @Nullable + static GeckoRuntime getInstance() { + return sRuntime; + } + + /** + * Called by mozilla::dom::ClientOpenWindow to retrieve the window id to use for a + * ServiceWorkerClients.openWindow() request. + * + * @param url validated Url being requested to be opened in a new window. + * @return SessionID to use for the request. + */ + @SuppressLint("WrongThread") // for .isOpen() which is called on the UI thread + @WrapForJNI(calledFrom = "gecko") + private static @NonNull GeckoResult<String> serviceWorkerOpenWindow(final @NonNull String url) { + if (sRuntime != null && sRuntime.mServiceWorkerDelegate != null) { + final GeckoResult<String> result = new GeckoResult<>(); + // perform the onOpenWindow call in the UI thread + ThreadUtils.runOnUiThread( + () -> { + sRuntime + .mServiceWorkerDelegate + .onOpenWindow(url) + .accept( + session -> { + if (session != null) { + if (!session.isOpen()) { + session.open(sRuntime); + } + session.loadUri(url); + result.complete(session.getId()); + } else { + result.complete(null); + } + }); + }); + return result; + } else { + return GeckoResult.fromException( + new java.lang.RuntimeException("No available Service Worker delegate.")); + } + } + + /** + * Attach the runtime to the given context. + * + * @param context The new context to attach to. + */ + @UiThread + public void attachTo(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + if (DEBUG) { + Log.d(LOGTAG, "attachTo " + context.getApplicationContext()); + } + final Context appContext = context.getApplicationContext(); + if (!appContext.equals(GeckoAppShell.getApplicationContext())) { + GeckoAppShell.setApplicationContext(appContext); + } + } + + private final BundleEventListener mEventListener = + new BundleEventListener() { + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + final Class<?> crashHandler = GeckoRuntime.this.getSettings().mCrashHandler; + + if ("Gecko:Exited".equals(event) && mDelegate != null) { + mDelegate.onShutdown(); + EventDispatcher.getInstance() + .unregisterUiThreadListener(mEventListener, "Gecko:Exited"); + } else if ("GeckoView:Test:NewTab".equals(event)) { + final String url = message.getString("url", "about:blank"); + serviceWorkerOpenWindow(url) + .then( + (GeckoResult.OnValueListener<String, Void>) + value -> { + callback.sendSuccess(value); + return null; + }) + .exceptionally( + (GeckoResult.OnExceptionListener<Void>) + error -> { + callback.sendError(error + " Could not open tab."); + return null; + }); + } else if ("GeckoView:ChildCrashReport".equals(event) && crashHandler != null) { + final Context context = GeckoAppShell.getApplicationContext(); + final Intent i = new Intent(ACTION_CRASHED, null, context, crashHandler); + i.putExtra(EXTRA_MINIDUMP_PATH, message.getString(EXTRA_MINIDUMP_PATH)); + i.putExtra(EXTRA_EXTRAS_PATH, message.getString(EXTRA_EXTRAS_PATH)); + i.putExtra(EXTRA_CRASH_PROCESS_TYPE, message.getString(EXTRA_CRASH_PROCESS_TYPE)); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(i); + } else { + context.startService(i); + } + } + } + }; + + private static String getProcessName(final Context context) { + final ActivityManager manager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final List<ActivityManager.RunningAppProcessInfo> infos = manager.getRunningAppProcesses(); + if (infos == null) { + return null; + } + for (final ActivityManager.RunningAppProcessInfo info : infos) { + if (info.pid == Process.myPid()) { + return info.processName; + } + } + + return null; + } + + /* package */ boolean init( + final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) { + if (DEBUG) { + Log.d(LOGTAG, "init"); + } + int flags = GeckoThread.FLAG_PRELOAD_CHILD; + + if (settings.getPauseForDebuggerEnabled()) { + flags |= GeckoThread.FLAG_DEBUGGING; + } + + final Class<?> crashHandler = settings.getCrashHandler(); + if (crashHandler != null) { + try { + final ServiceInfo info = + context.getPackageManager().getServiceInfo(new ComponentName(context, crashHandler), 0); + if (info.processName.equals(getProcessName(context))) { + throw new IllegalArgumentException( + "Crash handler service must run in a separate process"); + } + + EventDispatcher.getInstance() + .registerUiThreadListener(mEventListener, "GeckoView:ChildCrashReport"); + + flags |= GeckoThread.FLAG_ENABLE_NATIVE_CRASHREPORTER; + } catch (final PackageManager.NameNotFoundException e) { + throw new IllegalArgumentException("Crash handler must be registered as a service"); + } + } + + GeckoAppShell.useMaxScreenDepth(settings.getUseMaxScreenDepth()); + GeckoAppShell.setDisplayDensityOverride(settings.getDisplayDensityOverride()); + GeckoAppShell.setDisplayDpiOverride(settings.getDisplayDpiOverride()); + GeckoAppShell.setScreenSizeOverride(settings.getScreenSizeOverride()); + GeckoAppShell.setCrashHandlerService(settings.getCrashHandler()); + GeckoFontScaleListener.getInstance().attachToContext(context, settings); + + Bundle extras = settings.getExtras(); + String[] args = settings.getArguments(); + Map<String, Object> prefs = settings.getPrefsMap(); + + // Older versions have problems with SnakeYaml + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String configFilePath = settings.getConfigFilePath(); + if (configFilePath == null) { + // Default to /data/local/tmp/$PACKAGE-geckoview-config.yaml if android:debuggable="true" + // or if this application is the current Android "debug_app", and to not read configuration + // from a file otherwise. + if (isApplicationDebuggable(context) || isApplicationCurrentDebugApp(context)) { + configFilePath = + String.format(CONFIG_FILE_PATH_TEMPLATE, context.getApplicationInfo().packageName); + } + } + + if (configFilePath != null && !configFilePath.isEmpty()) { + try { + final DebugConfig debugConfig = DebugConfig.fromFile(new File(configFilePath)); + Log.i(LOGTAG, "Adding debug configuration from: " + configFilePath); + prefs = debugConfig.mergeIntoPrefs(prefs); + args = debugConfig.mergeIntoArgs(args); + extras = debugConfig.mergeIntoExtras(extras); + } catch (final DebugConfig.ConfigException e) { + Log.w(LOGTAG, "Failed to add debug configuration from: " + configFilePath, e); + } catch (final FileNotFoundException e) { + } + } + } + + final GeckoThread.InitInfo info = + GeckoThread.InitInfo.builder() + .args(args) + .extras(extras) + .flags(flags) + .prefs(prefs) + .outFilePath(extras != null ? extras.getString("out_file") : null) + .build(); + + if (info.xpcshell + && !"org.mozilla.geckoview.test_runner" + .equals(context.getApplicationContext().getPackageName())) { + throw new IllegalArgumentException("Only the test app can run -xpcshell."); + } + + if (info.xpcshell) { + // Xpcshell tests need multi-e10s to work properly + settings.setProcessCount(BuildConfig.MOZ_ANDROID_CONTENT_SERVICE_COUNT); + } + + if (!GeckoThread.init(info)) { + Log.w(LOGTAG, "init failed (could not initiate GeckoThread)"); + return false; + } + + if (!GeckoThread.launch()) { + Log.w(LOGTAG, "init failed (GeckoThread already launched)"); + return false; + } + + mSettings = settings; + + // Bug 1453062 -- the EventDispatcher should really live here (or in GeckoThread) + EventDispatcher.getInstance() + .registerUiThreadListener(mEventListener, "Gecko:Exited", "GeckoView:Test:NewTab"); + + // Attach and commit settings. + mSettings.attachTo(this); + + // Initialize the system ClipboardManager by accessing it on the main thread. + GeckoAppShell.getApplicationContext().getSystemService(Context.CLIPBOARD_SERVICE); + + // Add process lifecycle listener to react to backgrounding events. + ProcessLifecycleOwner.get().getLifecycle().addObserver(new LifecycleListener()); + + // Add Display Manager listener to listen screen orientation change. + if (mScreenChangeListener != null) { + mScreenChangeListener.enable(); + } + + mProfilerController.addMarker( + "GeckoView Initialization START", mProfilerController.getProfilerTime()); + return true; + } + + private boolean isApplicationDebuggable(final @NonNull Context context) { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + return (applicationInfo.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + private boolean isApplicationCurrentDebugApp(final @NonNull Context context) { + final ApplicationInfo applicationInfo = context.getApplicationInfo(); + + final String currentDebugApp; + if (Build.VERSION.SDK_INT >= 17) { + currentDebugApp = + Settings.Global.getString(context.getContentResolver(), Settings.Global.DEBUG_APP); + } else { + currentDebugApp = + Settings.System.getString(context.getContentResolver(), Settings.System.DEBUG_APP); + } + return applicationInfo.packageName.equals(currentDebugApp); + } + + /* package */ void setDefaultPrefs(final GeckoBundle prefs) { + EventDispatcher.getInstance().dispatch("GeckoView:SetDefaultPrefs", prefs); + } + + /** + * Create a new runtime with default settings and attach it to the given context. + * + * <p>Create will throw if there is already an active Gecko instance running, to prevent that, + * bind the runtime to the process lifetime instead of the activity lifetime. + * + * @param context The context of the runtime. + * @return An initialized runtime. + */ + @UiThread + public static @NonNull GeckoRuntime create(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + return create(context, new GeckoRuntimeSettings()); + } + + /** + * Returns a WebExtensionController for this GeckoRuntime. + * + * @return an instance of {@link WebExtensionController}. + */ + @UiThread + public @NonNull WebExtensionController getWebExtensionController() { + return mWebExtensionController; + } + + /** + * Returns the ContentBlockingController for this GeckoRuntime. + * + * @return An instance of {@link ContentBlockingController}. + */ + @UiThread + public @NonNull ContentBlockingController getContentBlockingController() { + return mContentBlockingController; + } + + /** + * Returns a ProfilerController for this GeckoRuntime. + * + * @return an instance of {@link ProfilerController}. + */ + @UiThread + public @NonNull ProfilerController getProfilerController() { + return mProfilerController; + } + + /** + * Create a new runtime with the given settings and attach it to the given context. + * + * <p>Create will throw if there is already an active Gecko instance running, to prevent that, + * bind the runtime to the process lifetime instead of the activity lifetime. + * + * @param context The context of the runtime. + * @param settings The settings for the runtime. + * @return An initialized runtime. + */ + @UiThread + public static @NonNull GeckoRuntime create( + final @NonNull Context context, final @NonNull GeckoRuntimeSettings settings) { + ThreadUtils.assertOnUiThread(); + if (DEBUG) { + Log.d(LOGTAG, "create " + context); + } + + final GeckoRuntime runtime = new GeckoRuntime(); + runtime.attachTo(context); + + if (!runtime.init(context, settings)) { + throw new IllegalStateException("Failed to initialize GeckoRuntime"); + } + + context.registerComponentCallbacks(runtime.mMemoryController); + + return runtime; + } + + /** Shutdown the runtime. This will invalidate all attached sessions. */ + @AnyThread + public void shutdown() { + if (DEBUG) { + Log.d(LOGTAG, "shutdown"); + } + + GeckoSystemStateListener.getInstance().shutdown(); + + if (mScreenChangeListener != null) { + mScreenChangeListener.disable(); + } + + GeckoThread.forceQuit(); + } + + public interface Delegate { + /** + * This is called when the runtime shuts down. Any GeckoSession instances that were opened with + * this instance are now considered closed. + */ + @UiThread + void onShutdown(); + } + + /** + * Set a delegate for receiving callbacks relevant to to this GeckoRuntime. + * + * @param delegate an implementation of {@link GeckoRuntime.Delegate}. + */ + @UiThread + public void setDelegate(final @Nullable Delegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Returns the current delegate, if any. + * + * @return an instance of {@link GeckoRuntime.Delegate} or null if no delegate has been set. + */ + @UiThread + public @Nullable Delegate getDelegate() { + return mDelegate; + } + + /** + * Set the {@link Autocomplete.StorageDelegate} instance on this runtime. This delegate is + * required for handling autocomplete storage requests. + * + * @param delegate The {@link Autocomplete.StorageDelegate} handling autocomplete storage + * requests. + */ + @UiThread + public void setAutocompleteStorageDelegate( + final @Nullable Autocomplete.StorageDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mAutocompleteStorageProxy.setDelegate(delegate); + } + + /** + * Get the {@link Autocomplete.StorageDelegate} instance set on this runtime. + * + * @return The {@link Autocomplete.StorageDelegate} set on this runtime. + */ + @UiThread + public @Nullable Autocomplete.StorageDelegate getAutocompleteStorageDelegate() { + ThreadUtils.assertOnUiThread(); + return mAutocompleteStorageProxy.getDelegate(); + } + + @UiThread + public interface ServiceWorkerDelegate { + + /** + * This is called when a service worker tries to open a new window using client.openWindow() The + * GeckoView application should provide an open {@link GeckoSession} to open the url. + * + * @param url Url which the Service Worker wishes to open in a new window. + * @return New or existing open {@link GeckoSession} in which to open the requested url. + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service + * Worker API</a> + * @see <a + * href="https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow">openWindow()</a> + */ + @UiThread + @NonNull + GeckoResult<GeckoSession> onOpenWindow(@NonNull String url); + } + + /** + * Sets the {@link ServiceWorkerDelegate} to be used for Service Worker requests. + * + * @param serviceWorkerDelegate An instance of {@link ServiceWorkerDelegate}. + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API">Service + * Worker API</a> + */ + @UiThread + public void setServiceWorkerDelegate( + final @Nullable ServiceWorkerDelegate serviceWorkerDelegate) { + mServiceWorkerDelegate = serviceWorkerDelegate; + } + + /** + * Gets the {@link ServiceWorkerDelegate} to be used for Service Worker requests. + * + * @return the {@link ServiceWorkerDelegate} instance set by {@link #setServiceWorkerDelegate} + */ + @UiThread + @Nullable + public ServiceWorkerDelegate getServiceWorkerDelegate() { + return mServiceWorkerDelegate; + } + + /** + * Sets the delegate to be used for handling Web Notifications. + * + * @param delegate An instance of {@link WebNotificationDelegate}. + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web + * Notifications</a> + */ + @UiThread + public void setWebNotificationDelegate(final @Nullable WebNotificationDelegate delegate) { + mNotificationDelegate = delegate; + } + + @WrapForJNI + /* package */ boolean usesDarkTheme() { + switch (getSettings().getPreferredColorScheme()) { + case GeckoRuntimeSettings.COLOR_SCHEME_SYSTEM: + return GeckoSystemStateListener.getInstance().isNightMode(); + case GeckoRuntimeSettings.COLOR_SCHEME_DARK: + return true; + case GeckoRuntimeSettings.COLOR_SCHEME_LIGHT: + default: + return false; + } + } + + /** + * Returns the current WebNotificationDelegate, if any + * + * @return an instance of WebNotificationDelegate or null if no delegate has been set + */ + @WrapForJNI + @UiThread + public @Nullable WebNotificationDelegate getWebNotificationDelegate() { + return mNotificationDelegate; + } + + @WrapForJNI + @AnyThread + private void notifyOnShow(final WebNotification notification) { + ThreadUtils.runOnUiThread( + () -> { + if (mNotificationDelegate != null) { + mNotificationDelegate.onShowNotification(notification); + } + }); + } + + @WrapForJNI + @AnyThread + private void notifyOnClose(final WebNotification notification) { + ThreadUtils.runOnUiThread( + () -> { + if (mNotificationDelegate != null) { + mNotificationDelegate.onCloseNotification(notification); + } + }); + } + + /** + * This is used to allow GeckoRuntime to start activities via the embedding application (and + * {@link android.app.Activity}). Currently this is used to invoke the Google Play FIDO Activity + * in order to integrate with the Web Authentication API. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API">Web + * Authentication API</a> + */ + public interface ActivityDelegate { + /** + * Sometimes GeckoView needs the application to perform a {@link + * android.app.Activity#startActivityForResult(Intent, int)} on its behalf. Implementations of + * this method should call that based on the information in the passed {@link PendingIntent}, + * collect the result, and resolve the returned {@link GeckoResult} with that data. If the + * Activity does not return {@link android.app.Activity#RESULT_OK}, the {@link GeckoResult} must + * be completed with an exception of your choosing. + * + * @param intent The {@link PendingIntent} to launch + * @return A {@link GeckoResult} that is eventually resolved with the Activity result. + */ + @UiThread + @Nullable + GeckoResult<Intent> onStartActivityForResult(@NonNull PendingIntent intent); + } + + /** + * Set the {@link ActivityDelegate} instance on this runtime. This delegate is used to provide + * GeckoView support for launching external activities and receiving results from those + * activities. + * + * @param delegate The {@link ActivityDelegate} handling intent launching requests. + */ + @UiThread + public void setActivityDelegate(final @Nullable ActivityDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mActivityDelegate = delegate; + } + + /** + * Get the {@link ActivityDelegate} instance set on this runtime, if any, + * + * @return The {@link ActivityDelegate} set on this runtime. + */ + @UiThread + public @Nullable ActivityDelegate getActivityDelegate() { + ThreadUtils.assertOnUiThread(); + return mActivityDelegate; + } + + @AnyThread + /* package */ GeckoResult<Intent> startActivityForResult(final @NonNull PendingIntent intent) { + if (!ThreadUtils.isOnUiThread()) { + // Delegates expect to be called on the UI thread. + final GeckoResult<Intent> result = new GeckoResult<>(); + + ThreadUtils.runOnUiThread( + () -> { + final GeckoResult<Intent> delegateResult = startActivityForResult(intent); + if (delegateResult != null) { + delegateResult.accept( + val -> result.complete(val), e -> result.completeExceptionally(e)); + } else { + result.completeExceptionally(new IllegalStateException("No result")); + } + }); + + return result; + } + + if (mActivityDelegate == null) { + return GeckoResult.fromException(new IllegalStateException("No delegate attached")); + } + + @SuppressLint("WrongThread") + GeckoResult<Intent> result = mActivityDelegate.onStartActivityForResult(intent); + if (result == null) { + result = GeckoResult.fromException(new IllegalStateException("No result")); + } + + return result; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull GeckoRuntimeSettings getSettings() { + return mSettings; + } + + /** Notify Gecko that the screen orientation has changed. */ + @UiThread + public void orientationChanged() { + ThreadUtils.assertOnUiThread(); + GeckoScreenOrientation.getInstance().update(); + } + + /** + * Notify Gecko that the device configuration has changed. + * + * @param newConfig The new Configuration object, {@link android.content.res.Configuration}. + */ + @UiThread + public void configurationChanged(final @NonNull Configuration newConfig) { + ThreadUtils.assertOnUiThread(); + GeckoSystemStateListener.getInstance().updateNightMode(newConfig.uiMode); + } + + /** + * Notify Gecko that the screen orientation has changed. + * + * @param newOrientation The new screen orientation, as retrieved e.g. from the current {@link + * android.content.res.Configuration}. + */ + @UiThread + public void orientationChanged(final int newOrientation) { + ThreadUtils.assertOnUiThread(); + GeckoScreenOrientation.getInstance().update(newOrientation); + } + + /** + * Get the orientation controller for this runtime. The orientation controller can be used to + * manage changes to and locking of the screen orientation. + * + * @return The {@link OrientationController} for this instance. + */ + @UiThread + public @NonNull OrientationController getOrientationController() { + ThreadUtils.assertOnUiThread(); + + if (mOrientationController == null) { + mOrientationController = new OrientationController(); + } + return mOrientationController; + } + + /** + * Converts GeckoScreenOrientation to ActivityInfo orientation + * + * @return A {@link ActivityInfo} orientation. + */ + @AnyThread + private int toAndroidOrientation(final int geckoOrientation) { + if (geckoOrientation == ScreenOrientation.PORTRAIT_PRIMARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.PORTRAIT_SECONDARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_PRIMARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE_SECONDARY.value) { + return ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.DEFAULT.value) { + return ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED; + } else if (geckoOrientation == ScreenOrientation.PORTRAIT.value) { + return ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (geckoOrientation == ScreenOrientation.LANDSCAPE.value) { + return ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (geckoOrientation == ScreenOrientation.ANY.value) { + return ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } + return ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } + + /** + * Lock screen orientation using OrientationController's onOrientationLock. + * + * @return A {@link GeckoResult} that resolves an orientation lock. + */ + @WrapForJNI(calledFrom = "gecko") + private @NonNull GeckoResult<Boolean> lockScreenOrientation(final int aOrientation) { + final GeckoResult<Boolean> res = new GeckoResult<>(); + ThreadUtils.runOnUiThread( + () -> { + final OrientationController.OrientationDelegate delegate = + getOrientationController().getDelegate(); + if (delegate == null) { + // Delegate is not set + res.completeExceptionally(new Exception("Not supported")); + return; + } + final GeckoResult<AllowOrDeny> response = + delegate.onOrientationLock(toAndroidOrientation(aOrientation)); + if (response == null) { + // Delegate is default. So lock orientation is not implemented + res.completeExceptionally(new Exception("Not supported")); + return; + } + res.completeFrom(response.map(v -> v == AllowOrDeny.ALLOW)); + }); + return res; + } + + /** Unlock screen orientation using OrientationController's onOrientationUnlock. */ + @WrapForJNI(calledFrom = "gecko") + private void unlockScreenOrientation() { + ThreadUtils.runOnUiThread( + () -> { + final OrientationController.OrientationDelegate delegate = + getOrientationController().getDelegate(); + if (delegate != null) { + delegate.onOrientationUnlock(); + } + }); + } + + /** + * Get the storage controller for this runtime. The storage controller can be used to manage + * persistent storage data accumulated by {@link GeckoSession}. + * + * @return The {@link StorageController} for this instance. + */ + @UiThread + public @NonNull StorageController getStorageController() { + ThreadUtils.assertOnUiThread(); + + if (mStorageController == null) { + mStorageController = new StorageController(); + } + return mStorageController; + } + + /** + * Get the Web Push controller for this runtime. The Web Push controller can be used to allow + * content to use the Web Push API. + * + * @return The {@link WebPushController} for this instance. + */ + @UiThread + public @NonNull WebPushController getWebPushController() { + ThreadUtils.assertOnUiThread(); + + if (mPushController == null) { + mPushController = new WebPushController(); + } + + return mPushController; + } + + /** + * Appends notes to crash report. + * + * @param notes The application notes to append to the crash report. + */ + @AnyThread + public void appendAppNotesToCrashReport(@NonNull final String notes) { + final String notesWithNewLine = notes + "\n"; + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + GeckoAppShell.nativeAppendAppNotesToCrashReport(notesWithNewLine); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + GeckoAppShell.class, + "nativeAppendAppNotesToCrashReport", + String.class, + notesWithNewLine); + } + // This function already adds a newline + GeckoAppShell.appendAppNotesToCrashReport(notes); + } + + @Override // Parcelable + @AnyThread + public int describeContents() { + return 0; + } + + @Override // Parcelable + @AnyThread + public void writeToParcel(final Parcel out, final int flags) { + out.writeParcelable(mSettings, flags); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + mSettings = source.readParcelable(getClass().getClassLoader()); + } + + public static final Parcelable.Creator<GeckoRuntime> CREATOR = + new Parcelable.Creator<GeckoRuntime>() { + @Override + @AnyThread + public GeckoRuntime createFromParcel(final Parcel in) { + final GeckoRuntime runtime = new GeckoRuntime(); + runtime.readFromParcel(in); + return runtime; + } + + @Override + @AnyThread + public GeckoRuntime[] newArray(final int size) { + return new GeckoRuntime[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java new file mode 100644 index 0000000000..85bfa6dd8f --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoRuntimeSettings.java @@ -0,0 +1,1305 @@ +/* -*- 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.geckoview; + +import static android.os.Build.VERSION; + +import android.app.Service; +import android.graphics.Rect; +import android.os.Bundle; +import android.os.LocaleList; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.LinkedHashMap; +import java.util.Locale; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoSystemStateListener; +import org.mozilla.gecko.util.GeckoBundle; + +@AnyThread +public final class GeckoRuntimeSettings extends RuntimeSettings { + private static final String LOGTAG = "GeckoRuntimeSettings"; + + /** Settings builder used to construct the settings object. */ + @AnyThread + public static final class Builder extends RuntimeSettings.Builder<GeckoRuntimeSettings> { + @Override + protected @NonNull GeckoRuntimeSettings newSettings( + final @Nullable GeckoRuntimeSettings settings) { + return new GeckoRuntimeSettings(settings); + } + + /** + * Set the custom Gecko process arguments. + * + * @param args The Gecko process arguments. + * @return This Builder instance. + */ + public @NonNull Builder arguments(final @NonNull String[] args) { + if (args == null) { + throw new IllegalArgumentException("Arguments must not be null"); + } + getSettings().mArgs = args; + return this; + } + + /** + * Set the custom Gecko intent extras. + * + * @param extras The Gecko intent extras. + * @return This Builder instance. + */ + public @NonNull Builder extras(final @NonNull Bundle extras) { + if (extras == null) { + throw new IllegalArgumentException("Extras must not be null"); + } + getSettings().mExtras = extras; + return this; + } + + /** + * Path to configuration file from which GeckoView will read configuration options such as Gecko + * process arguments, environment variables, and preferences. + * + * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} > 21</code>, on + * older devices this will be silently ignored. + * + * @param configFilePath Configuration file path to read from, or <code>null</code> to use + * default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml</code>. + * @return This Builder instance. + */ + public @NonNull Builder configFilePath(final @Nullable String configFilePath) { + getSettings().mConfigFilePath = configFilePath; + return this; + } + + /** + * Set whether JavaScript support should be enabled. + * + * @param flag A flag determining whether JavaScript should be enabled. Default is true. + * @return This Builder instance. + */ + public @NonNull Builder javaScriptEnabled(final boolean flag) { + getSettings().mJavaScript.set(flag); + return this; + } + + /** + * Set whether remote debugging support should be enabled. + * + * @param enabled True if remote debugging should be enabled. + * @return This Builder instance. + */ + public @NonNull Builder remoteDebuggingEnabled(final boolean enabled) { + getSettings().mRemoteDebugging.set(enabled); + return this; + } + + /** + * Set whether support for web fonts should be enabled. + * + * @param flag A flag determining whether web fonts should be enabled. Default is true. + * @return This Builder instance. + */ + public @NonNull Builder webFontsEnabled(final boolean flag) { + getSettings().mWebFonts.set(flag ? 1 : 0); + return this; + } + + /** + * Set whether there should be a pause during startup. This is useful if you need to wait for a + * debugger to attach. + * + * @param enabled A flag determining whether there will be a pause early in startup. Defaults to + * false. + * @return This Builder. + */ + public @NonNull Builder pauseForDebugger(final boolean enabled) { + getSettings().mDebugPause = enabled; + return this; + } + /** + * Set whether the to report the full bit depth of the device. + * + * <p>By default, 24 bits are reported for high memory devices and 16 bits for low memory + * devices. If set to true, the device's maximum bit depth is reported. On most modern devices + * this will be 32 bit screen depth. + * + * @param enable A flag determining whether maximum screen depth should be used. + * @return This Builder. + */ + public @NonNull Builder useMaxScreenDepth(final boolean enable) { + getSettings().mUseMaxScreenDepth = enable; + return this; + } + + /** + * Set whether web manifest support is enabled. + * + * <p>This controls if Gecko actually downloads, or "obtains", web manifests and processes them. + * Without setting this pref, trying to obtain a manifest throws. + * + * @param enabled A flag determining whether Web Manifest processing support is enabled. + * @return The builder instance. + */ + public @NonNull Builder webManifest(final boolean enabled) { + getSettings().mWebManifest.set(enabled); + return this; + } + + /** + * Set whether or not web console messages should go to logcat. + * + * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use + * of the console API. + * + * @param enabled A flag determining whether or not web console messages should be printed to + * logcat. + * @return The builder instance. + */ + public @NonNull Builder consoleOutput(final boolean enabled) { + getSettings().mConsoleOutput.set(enabled); + return this; + } + + /** + * Set whether or not font sizes in web content should be automatically scaled according to the + * device's current system font scale setting. + * + * @param enabled A flag determining whether or not font sizes should be scaled automatically to + * match the device's system font scale. + * @return The builder instance. + */ + public @NonNull Builder automaticFontSizeAdjustment(final boolean enabled) { + getSettings().setAutomaticFontSizeAdjustment(enabled); + return this; + } + + /** + * Set a font size factor that will operate as a global text zoom. All font sizes will be + * multiplied by this factor. + * + * <p>The default factor is 1.0. + * + * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic + * font size adjustment} has already been enabled. + * + * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 + * disables both this feature and {@link Builder#fontInflation font inflation}. + * @return The builder instance. + */ + public @NonNull Builder fontSizeFactor(final float fontSizeFactor) { + getSettings().setFontSizeFactor(fontSizeFactor); + return this; + } + + /** + * Enable the Enterprise Roots feature. + * + * <p>When Enabled, GeckoView will fetch the third-party root certificates added to the Android + * OS CA store and will use them internally. + * + * @param enabled whether to enable this feature or not + * @return The builder instance + */ + public @NonNull Builder enterpriseRootsEnabled(final boolean enabled) { + getSettings().setEnterpriseRootsEnabled(enabled); + return this; + } + + /** + * Set whether or not font inflation for non mobile-friendly pages should be enabled. The + * default value of this setting is <code>false</code>. + * + * <p>When enabled, font sizes will be increased on all pages that are lacking a <meta> + * viewport tag and have been loaded in a session using {@link + * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic + * will attempt to increase font sizes for the main text content of the page only. + * + * <p>The magnitude of font inflation applied depends on the {@link Builder#fontSizeFactor font + * size factor} currently in use. + * + * <p>This setting cannot be modified if {@link Builder#automaticFontSizeAdjustment automatic + * font size adjustment} has already been enabled. + * + * @param enabled A flag determining whether or not font inflation should be enabled. + * @return The builder instance. + */ + public @NonNull Builder fontInflation(final boolean enabled) { + getSettings().setFontInflationEnabled(enabled); + return this; + } + + /** + * Set the display density override. + * + * @param density The display density value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder displayDensityOverride(final float density) { + getSettings().mDisplayDensityOverride = density; + return this; + } + + /** + * Set the display DPI override. + * + * @param dpi The display DPI value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder displayDpiOverride(final int dpi) { + getSettings().mDisplayDpiOverride = dpi; + return this; + } + + /** + * Set the screen size override. + * + * @param width The screen width value to use for overriding the system default. + * @param height The screen height value to use for overriding the system default. + * @return The builder instance. + */ + public @NonNull Builder screenSizeOverride(final int width, final int height) { + getSettings().mScreenWidthOverride = width; + getSettings().mScreenHeightOverride = height; + return this; + } + + /** + * Set whether login forms should be filled automatically if only one viable candidate is + * provided via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}. + * + * @param enabled A flag determining whether login autofill should be enabled. + * @return The builder instance. + */ + public @NonNull Builder loginAutofillEnabled(final boolean enabled) { + getSettings().setLoginAutofillEnabled(enabled); + return this; + } + + /** + * When set, the specified {@link android.app.Service} will be started by an {@link + * android.content.Intent} with action {@link GeckoRuntime#ACTION_CRASHED} when a crash is + * encountered. Crash details can be found in the Intent extras, such as {@link + * GeckoRuntime#EXTRA_MINIDUMP_PATH}. <br> + * <br> + * The crash handler Service must be declared to run in a different process from the {@link + * GeckoRuntime}. Additionally, the handler will be run as a foreground service, so the normal + * rules about activating a foreground service apply. <br> + * <br> + * In practice, you have one of three options once the crash handler is started: + * + * <ul> + * <li>Call {@link android.app.Service#startForeground(int, android.app.Notification)}. You + * can then take as much time as necessary to report the crash. + * <li>Start an activity. Unless you also call {@link android.app.Service#startForeground(int, + * android.app.Notification)} this should be in a different process from the crash + * handler, since Android will kill the crash handler process as part of the background + * execution limitations. + * <li>Schedule work via {@link android.app.job.JobScheduler}. This will allow you to do + * substantial work in the background without execution limits. + * </ul> + * + * <br> + * You can use {@link CrashReporter} to send the report to Mozilla, which provides Mozilla with + * data needed to fix the crash. Be aware that the minidump may contain personally identifiable + * information (PII). Consult Mozilla's <a href="https://www.mozilla.org/en-US/privacy/">privacy + * policy</a> for information on how this data will be handled. + * + * @param handler The class for the crash handler Service. + * @return This builder instance. + * @see <a href="https://developer.android.com/about/versions/oreo/background">Android + * Background Execution Limits</a> + * @see GeckoRuntime#ACTION_CRASHED + */ + public @NonNull Builder crashHandler(final @Nullable Class<? extends Service> handler) { + getSettings().mCrashHandler = handler; + return this; + } + + /** + * Set the locale. + * + * @param requestedLocales List of locale codes in Gecko format ("en" or "en-US"). + * @return The builder instance. + */ + public @NonNull Builder locales(final @Nullable String[] requestedLocales) { + getSettings().mRequestedLocales = requestedLocales; + return this; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull Builder contentBlocking(final @NonNull ContentBlocking.Settings cb) { + getSettings().mContentBlocking = cb; + return this; + } + + /** + * Sets the preferred color scheme override for web content. + * + * @param scheme The preferred color scheme. Must be one of the {@link + * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants. + * @return This Builder instance. + */ + public @NonNull Builder preferredColorScheme(final @ColorScheme int scheme) { + getSettings().setPreferredColorScheme(scheme); + return this; + } + + /** + * Set whether auto-zoom to editable fields should be enabled. + * + * @param flag True if auto-zoom should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder inputAutoZoomEnabled(final boolean flag) { + getSettings().mInputAutoZoom.set(flag); + return this; + } + + /** + * Set whether double tap zooming should be enabled. + * + * @param flag True if double tap zooming should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder doubleTapZoomingEnabled(final boolean flag) { + getSettings().mDoubleTapZooming.set(flag); + return this; + } + + /** + * Sets the WebGL MSAA level. + * + * @param level number of MSAA samples, 0 if MSAA should be disabled. + * @return This Builder instance. + */ + public @NonNull Builder glMsaaLevel(final int level) { + getSettings().mGlMsaaLevel.set(level); + return this; + } + + /** + * Add a {@link RuntimeTelemetry.Delegate} instance to this GeckoRuntime. This delegate can be + * used by the app to receive streaming telemetry data from GeckoView. + * + * @param delegate the delegate that will handle telemetry + * @return The builder instance. + */ + public @NonNull Builder telemetryDelegate(final @NonNull RuntimeTelemetry.Delegate delegate) { + getSettings().mTelemetryProxy = new RuntimeTelemetry.Proxy(delegate); + getSettings().mTelemetryEnabled.set(true); + return this; + } + + /** + * Enables GeckoView and Gecko Logging. Logging is on by default. Does not control all logging + * in Gecko. Logging done in Java code must be stripped out at build time. + * + * @param enable True if logging is enabled. + * @return This Builder instance. + */ + public @NonNull Builder debugLogging(final boolean enable) { + getSettings().mDevToolsConsoleToLogcat.set(enable); + getSettings().mConsoleServiceToLogcat.set(enable); + getSettings().mGeckoViewLogLevel.set(enable ? "Debug" : "Fatal"); + return this; + } + + /** + * Sets whether or not about:config should be enabled. This is a page that allows users to + * directly modify Gecko preferences. Modification of some preferences may cause the app to + * break in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc. + * + * @param flag True if about:config should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder aboutConfigEnabled(final boolean flag) { + getSettings().mAboutConfig.set(flag); + return this; + } + + /** + * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set + * on the viewport. + * + * @param flag True if force user scalable zooming should be enabled, false otherwise. + * @return This Builder instance. + */ + public @NonNull Builder forceUserScalableEnabled(final boolean flag) { + getSettings().mForceUserScalable.set(flag); + return this; + } + + /** + * Sets whether and where insecure (non-HTTPS) connections are allowed. + * + * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + * @return This Builder instance. + */ + public @NonNull Builder allowInsecureConnections(final @HttpsOnlyMode int level) { + getSettings().setAllowInsecureConnections(level); + return this; + } + } + + private GeckoRuntime mRuntime; + /* package */ String[] mArgs; + /* package */ Bundle mExtras; + /* package */ String mConfigFilePath; + + /* package */ ContentBlocking.Settings mContentBlocking; + + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull ContentBlocking.Settings getContentBlocking() { + return mContentBlocking; + } + + /* package */ final Pref<Boolean> mWebManifest = new Pref<Boolean>("dom.manifest.enabled", true); + /* package */ final Pref<Boolean> mJavaScript = new Pref<Boolean>("javascript.enabled", true); + /* package */ final Pref<Boolean> mRemoteDebugging = + new Pref<Boolean>("devtools.debugger.remote-enabled", false); + /* package */ final Pref<Integer> mWebFonts = + new Pref<Integer>("browser.display.use_document_fonts", 1); + /* package */ final Pref<Boolean> mConsoleOutput = + new Pref<Boolean>("geckoview.console.enabled", false); + /* package */ final Pref<Integer> mFontSizeFactor = new Pref<>("font.size.systemFontScale", 100); + /* package */ final Pref<Boolean> mEnterpriseRootsEnabled = + new Pref<>("security.enterprise_roots.enabled", false); + /* package */ final Pref<Integer> mFontInflationMinTwips = + new Pref<>("font.size.inflation.minTwips", 0); + /* package */ final Pref<Boolean> mInputAutoZoom = new Pref<>("formhelper.autozoom", true); + /* package */ final Pref<Boolean> mDoubleTapZooming = + new Pref<>("apz.allow_double_tap_zooming", true); + /* package */ final Pref<Integer> mGlMsaaLevel = new Pref<>("webgl.msaa-samples", 4); + /* package */ final Pref<Boolean> mTelemetryEnabled = + new Pref<>("toolkit.telemetry.geckoview.streaming", false); + /* package */ final Pref<String> mGeckoViewLogLevel = new Pref<>("geckoview.logging", "Debug"); + /* package */ final Pref<Boolean> mConsoleServiceToLogcat = + new Pref<>("consoleservice.logcat", true); + /* package */ final Pref<Boolean> mDevToolsConsoleToLogcat = + new Pref<>("devtools.console.stdout.chrome", true); + /* package */ final Pref<Boolean> mAboutConfig = new Pref<>("general.aboutConfig.enable", false); + /* package */ final Pref<Boolean> mForceUserScalable = + new Pref<>("browser.ui.zoom.force-user-scalable", false); + /* package */ final Pref<Boolean> mAutofillLogins = + new Pref<Boolean>("signon.autofillForms", true); + /* package */ final Pref<Boolean> mHttpsOnly = + new Pref<Boolean>("dom.security.https_only_mode", false); + /* package */ final Pref<Boolean> mHttpsOnlyPrivateMode = + new Pref<Boolean>("dom.security.https_only_mode_pbm", false); + /* package */ final Pref<Integer> mProcessCount = new Pref<>("dom.ipc.processCount", 2); + + /* package */ int mPreferredColorScheme = COLOR_SCHEME_SYSTEM; + + /* package */ boolean mForceEnableAccessibility; + /* package */ boolean mDebugPause; + /* package */ boolean mUseMaxScreenDepth; + /* package */ float mDisplayDensityOverride = -1.0f; + /* package */ int mDisplayDpiOverride; + /* package */ int mScreenWidthOverride; + /* package */ int mScreenHeightOverride; + /* package */ Class<? extends Service> mCrashHandler; + /* package */ String[] mRequestedLocales; + /* package */ RuntimeTelemetry.Proxy mTelemetryProxy; + + /** + * Attach and commit the settings to the given runtime. + * + * @param runtime The runtime to attach to. + */ + /* package */ void attachTo(final @NonNull GeckoRuntime runtime) { + mRuntime = runtime; + commit(); + + if (mTelemetryProxy != null) { + mTelemetryProxy.attach(); + } + } + + @Override // RuntimeSettings + public @Nullable GeckoRuntime getRuntime() { + return mRuntime; + } + + /* package */ GeckoRuntimeSettings() { + this(null); + } + + /* package */ GeckoRuntimeSettings(final @Nullable GeckoRuntimeSettings settings) { + super(/* parent */ null); + + if (settings == null) { + mArgs = new String[0]; + mExtras = new Bundle(); + mContentBlocking = new ContentBlocking.Settings(this /* parent */, null /* settings */); + return; + } + + updateSettings(settings); + } + + private void updateSettings(final @NonNull GeckoRuntimeSettings settings) { + updatePrefs(settings); + + mArgs = settings.getArguments().clone(); + mExtras = new Bundle(settings.getExtras()); + mContentBlocking = new ContentBlocking.Settings(this /* parent */, settings.mContentBlocking); + + mForceEnableAccessibility = settings.mForceEnableAccessibility; + mDebugPause = settings.mDebugPause; + mUseMaxScreenDepth = settings.mUseMaxScreenDepth; + mDisplayDensityOverride = settings.mDisplayDensityOverride; + mDisplayDpiOverride = settings.mDisplayDpiOverride; + mScreenWidthOverride = settings.mScreenWidthOverride; + mScreenHeightOverride = settings.mScreenHeightOverride; + mCrashHandler = settings.mCrashHandler; + mRequestedLocales = settings.mRequestedLocales; + mConfigFilePath = settings.mConfigFilePath; + mTelemetryProxy = settings.mTelemetryProxy; + } + + /* package */ void commit() { + commitLocales(); + commitResetPrefs(); + } + + /** + * Get the custom Gecko process arguments. + * + * @return The Gecko process arguments. + */ + public @NonNull String[] getArguments() { + return mArgs; + } + + /** + * Get the custom Gecko intent extras. + * + * @return The Gecko intent extras. + */ + public @NonNull Bundle getExtras() { + return mExtras; + } + + /** + * Path to configuration file from which GeckoView will read configuration options such as Gecko + * process arguments, environment variables, and preferences. + * + * <p>Note: this feature is only available for <code>{@link VERSION#SDK_INT} > 21</code>. + * + * @return Path to configuration file from which GeckoView will read configuration options, or + * <code>null</code> for default location <code>/data/local/tmp/$PACKAGE-geckoview-config.yaml + * </code>. + */ + public @Nullable String getConfigFilePath() { + return mConfigFilePath; + } + + /** + * Get whether JavaScript support is enabled. + * + * @return Whether JavaScript support is enabled. + */ + public boolean getJavaScriptEnabled() { + return mJavaScript.get(); + } + + /** + * Set whether JavaScript support should be enabled. + * + * @param flag A flag determining whether JavaScript should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setJavaScriptEnabled(final boolean flag) { + mJavaScript.commit(flag); + return this; + } + + /** + * Get whether remote debugging support is enabled. + * + * @return True if remote debugging support is enabled. + */ + public boolean getRemoteDebuggingEnabled() { + return mRemoteDebugging.get(); + } + + /** + * Set whether remote debugging support should be enabled. + * + * @param enabled True if remote debugging should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setRemoteDebuggingEnabled(final boolean enabled) { + mRemoteDebugging.commit(enabled); + return this; + } + + /** + * Get whether web fonts support is enabled. + * + * @return Whether web fonts support is enabled. + */ + public boolean getWebFontsEnabled() { + return mWebFonts.get() != 0 ? true : false; + } + + /** + * Set whether support for web fonts should be enabled. + * + * @param flag A flag determining whether web fonts should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setWebFontsEnabled(final boolean flag) { + mWebFonts.commit(flag ? 1 : 0); + return this; + } + + /** + * Gets whether the pause-for-debugger is enabled or not. + * + * @return True if the pause is enabled. + */ + public boolean getPauseForDebuggerEnabled() { + return mDebugPause; + } + + /** + * Gets whether accessibility is force enabled or not. + * + * @return true if accessibility is force enabled. + */ + public boolean getForceEnableAccessibility() { + return mForceEnableAccessibility; + } + + /** + * Sets whether accessibility is force enabled or not. + * + * <p>Useful when testing accessibility. + * + * @param value whether accessibility is force enabled or not + * @return this GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setForceEnableAccessibility(final boolean value) { + mForceEnableAccessibility = value; + SessionAccessibility.setForceEnabled(value); + return this; + } + + /** + * Gets whether the compositor should use the maximum screen depth when rendering. + * + * @return True if the maximum screen depth should be used. + */ + public boolean getUseMaxScreenDepth() { + return mUseMaxScreenDepth; + } + + /** + * Gets the display density override value. + * + * @return Returns a positive number. Will return null if not set. + */ + public @Nullable Float getDisplayDensityOverride() { + if (mDisplayDensityOverride > 0.0f) { + return mDisplayDensityOverride; + } + return null; + } + + /** + * Gets the display DPI override value. + * + * @return Returns a positive number. Will return null if not set. + */ + public @Nullable Integer getDisplayDpiOverride() { + if (mDisplayDpiOverride > 0) { + return mDisplayDpiOverride; + } + return null; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable Class<? extends Service> getCrashHandler() { + return mCrashHandler; + } + + /** + * Gets the screen size override value. + * + * @return Returns a Rect containing the dimensions to use for the window size. Will return null + * if not set. + */ + public @Nullable Rect getScreenSizeOverride() { + if ((mScreenWidthOverride > 0) && (mScreenHeightOverride > 0)) { + return new Rect(0, 0, mScreenWidthOverride, mScreenHeightOverride); + } + return null; + } + + /** + * Gets the list of requested locales. + * + * @return A list of locale codes in Gecko format ("en" or "en-US"). + */ + public @Nullable String[] getLocales() { + return mRequestedLocales; + } + + /** + * Set the locale. + * + * @param requestedLocales An ordered list of locales in Gecko format ("en-US"). + */ + public void setLocales(final @Nullable String[] requestedLocales) { + mRequestedLocales = requestedLocales; + commitLocales(); + } + + private void commitLocales() { + final GeckoBundle data = new GeckoBundle(1); + data.putStringArray("requestedLocales", mRequestedLocales); + data.putString("acceptLanguages", computeAcceptLanguages()); + EventDispatcher.getInstance().dispatch("GeckoView:SetLocale", data); + } + + private String computeAcceptLanguages() { + final LinkedHashMap<String, String> locales = new LinkedHashMap<>(); + + // Explicitly-set app prefs come first: + if (mRequestedLocales != null) { + for (final String locale : mRequestedLocales) { + locales.put(locale.toLowerCase(Locale.ROOT), locale); + } + } + // OS prefs come second: + for (final String locale : getDefaultLocales()) { + final String localeLowerCase = locale.toLowerCase(Locale.ROOT); + if (!locales.containsKey(localeLowerCase)) { + locales.put(localeLowerCase, locale); + } + } + + return TextUtils.join(",", locales.values()); + } + + private static String[] getDefaultLocales() { + if (VERSION.SDK_INT >= 24) { + final LocaleList localeList = LocaleList.getDefault(); + final String[] locales = new String[localeList.size()]; + for (int i = 0; i < localeList.size(); i++) { + locales[i] = localeList.get(i).toLanguageTag(); + } + return locales; + } + final String[] locales = new String[1]; + final Locale locale = Locale.getDefault(); + if (VERSION.SDK_INT >= 21) { + locales[0] = locale.toLanguageTag(); + return locales; + } + + locales[0] = getLanguageTag(locale); + return locales; + } + + private static String getLanguageTag(final Locale locale) { + final StringBuilder out = new StringBuilder(locale.getLanguage()); + final String country = locale.getCountry(); + final String variant = locale.getVariant(); + if (!TextUtils.isEmpty(country)) { + out.append('-').append(country); + } + if (!TextUtils.isEmpty(variant)) { + out.append('-').append(variant); + } + // e.g. "en", "en-US", or "en-US-POSIX". + return out.toString(); + } + + /** + * Sets whether Web Manifest processing support is enabled. + * + * @param enabled A flag determining whether Web Manifest processing support is enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setWebManifestEnabled(final boolean enabled) { + mWebManifest.commit(enabled); + return this; + } + + /** + * Get whether or not Web Manifest processing support is enabled. + * + * @return True if web manifest processing support is enabled. + */ + public boolean getWebManifestEnabled() { + return mWebManifest.get(); + } + + /** + * Set whether or not web console messages should go to logcat. + * + * <p>Note: If enabled, Gecko performance may be negatively impacted if content makes heavy use of + * the console API. + * + * @param enabled A flag determining whether or not web console messages should be printed to + * logcat. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setConsoleOutputEnabled(final boolean enabled) { + mConsoleOutput.commit(enabled); + return this; + } + + /** + * Get whether or not web console messages are sent to logcat. + * + * @return True if console output is enabled. + */ + public boolean getConsoleOutputEnabled() { + return mConsoleOutput.get(); + } + + /** + * Set whether or not font sizes in web content should be automatically scaled according to the + * device's current system font scale setting. Enabling this will prevent modification of the + * {@link GeckoRuntimeSettings#setFontSizeFactor font size factor}. Disabling this setting will + * restore the previously used value for the {@link GeckoRuntimeSettings#getFontSizeFactor font + * size factor}. + * + * @param enabled A flag determining whether or not font sizes should be scaled automatically to + * match the device's system font scale. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setAutomaticFontSizeAdjustment(final boolean enabled) { + GeckoFontScaleListener.getInstance().setEnabled(enabled); + return this; + } + + /** + * Get whether or not the font sizes for web content are automatically adjusted to match the + * device's system font scale setting. + * + * @return True if font sizes are automatically adjusted. + */ + public boolean getAutomaticFontSizeAdjustment() { + return GeckoFontScaleListener.getInstance().getEnabled(); + } + + private static final int FONT_INFLATION_BASE_VALUE = 120; + + /** + * Set a font size factor that will operate as a global text zoom. All font sizes will be + * multiplied by this factor. + * + * <p>The default factor is 1.0. + * + * <p>Currently, any changes only take effect after a reload of the session. + * + * <p>This setting cannot be modified while {@link + * GeckoRuntimeSettings#setAutomaticFontSizeAdjustment automatic font size adjustment} is enabled. + * + * @param fontSizeFactor The factor to be used for scaling all text. Setting a value of 0 disables + * both this feature and {@link GeckoRuntimeSettings#setFontInflationEnabled font inflation}. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setFontSizeFactor(final float fontSizeFactor) { + if (getAutomaticFontSizeAdjustment()) { + throw new IllegalStateException("Not allowed when automatic font size adjustment is enabled"); + } + return setFontSizeFactorInternal(fontSizeFactor); + } + + /* + * Enable the Enteprise Roots feature. + * + * When Enabled, GeckoView will fetch the third-party root certificates added to the + * Android OS CA store and will use them internally. + * + * @param enabled whether to enable this feature or not + * @return This GeckoRuntimeSettings instance + */ + public @NonNull GeckoRuntimeSettings setEnterpriseRootsEnabled(final boolean enabled) { + mEnterpriseRootsEnabled.commit(enabled); + return this; + } + + /** + * Gets whether the Enteprise Roots feature is enabled or not. + * + * @return true if the feature is enabled, false otherwise. + */ + public boolean getEnterpriseRootsEnabled() { + return mEnterpriseRootsEnabled.get(); + } + + private static final float DEFAULT_FONT_SIZE_FACTOR = 1f; + + private float sanitizeFontSizeFactor(final float fontSizeFactor) { + if (fontSizeFactor < 0) { + if (BuildConfig.DEBUG_BUILD) { + throw new IllegalArgumentException("fontSizeFactor cannot be < 0"); + } else { + Log.e(LOGTAG, "fontSizeFactor cannot be < 0"); + return DEFAULT_FONT_SIZE_FACTOR; + } + } + + return fontSizeFactor; + } + + /* package */ @NonNull + GeckoRuntimeSettings setFontSizeFactorInternal(final float fontSizeFactor) { + final int fontSizePercentage = Math.round(sanitizeFontSizeFactor(fontSizeFactor) * 100); + mFontSizeFactor.commit(fontSizePercentage); + if (getFontInflationEnabled()) { + final int scaledFontInflation = Math.round(FONT_INFLATION_BASE_VALUE * fontSizeFactor); + mFontInflationMinTwips.commit(scaledFontInflation); + } + return this; + } + + /** + * Gets the currently applied font size factor. + * + * @return The currently applied font size factor. + */ + public float getFontSizeFactor() { + return mFontSizeFactor.get() / 100f; + } + + /** + * Set whether or not font inflation for non mobile-friendly pages should be enabled. The default + * value of this setting is <code>false</code>. + * + * <p>When enabled, font sizes will be increased on all pages that are lacking a <meta> + * viewport tag and have been loaded in a session using {@link + * GeckoSessionSettings#VIEWPORT_MODE_MOBILE}. To improve readability, the font inflation logic + * will attempt to increase font sizes for the main text content of the page only. + * + * <p>The magnitude of font inflation applied depends on the {@link + * GeckoRuntimeSettings#setFontSizeFactor font size factor} currently in use. + * + * <p>Currently, any changes only take effect after a reload of the session. + * + * @param enabled A flag determining whether or not font inflation should be enabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setFontInflationEnabled(final boolean enabled) { + final int minTwips = enabled ? Math.round(FONT_INFLATION_BASE_VALUE * getFontSizeFactor()) : 0; + mFontInflationMinTwips.commit(minTwips); + return this; + } + + /** + * Get whether or not font inflation for non mobile-friendly pages is currently enabled. + * + * @return True if font inflation is enabled. + */ + public boolean getFontInflationEnabled() { + return mFontInflationMinTwips.get() > 0; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({COLOR_SCHEME_LIGHT, COLOR_SCHEME_DARK, COLOR_SCHEME_SYSTEM}) + public @interface ColorScheme {} + + /** A light theme for web content is preferred. */ + public static final int COLOR_SCHEME_LIGHT = 0; + /** A dark theme for web content is preferred. */ + public static final int COLOR_SCHEME_DARK = 1; + /** The preferred color scheme will be based on system settings. */ + public static final int COLOR_SCHEME_SYSTEM = -1; + + /** + * Gets the preferred color scheme override for web content. + * + * @return One of the {@link GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants. + */ + public @ColorScheme int getPreferredColorScheme() { + return mPreferredColorScheme; + } + + /** + * Sets the preferred color scheme override for web content. + * + * @param scheme The preferred color scheme. Must be one of the {@link + * GeckoRuntimeSettings#COLOR_SCHEME_LIGHT COLOR_SCHEME_*} constants. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setPreferredColorScheme(final @ColorScheme int scheme) { + if (mPreferredColorScheme != scheme) { + mPreferredColorScheme = scheme; + GeckoSystemStateListener.onDeviceChanged(); + } + return this; + } + + /** + * Gets whether auto-zoom to editable fields is enabled. + * + * @return True if auto-zoom is enabled, false otherwise. + */ + public boolean getInputAutoZoomEnabled() { + return mInputAutoZoom.get(); + } + + /** + * Set whether auto-zoom to editable fields should be enabled. + * + * @param flag True if auto-zoom should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setInputAutoZoomEnabled(final boolean flag) { + mInputAutoZoom.commit(flag); + return this; + } + + /** + * Gets whether double-tap zooming is enabled. + * + * @return True if double-tap zooming is enabled, false otherwise. + */ + public boolean getDoubleTapZoomingEnabled() { + return mDoubleTapZooming.get(); + } + + /** + * Sets whether double tap zooming is enabled. + * + * @param flag true if double tap zooming should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setDoubleTapZoomingEnabled(final boolean flag) { + mDoubleTapZooming.commit(flag); + return this; + } + + /** + * Gets the current WebGL MSAA level. + * + * @return number of MSAA samples, 0 if MSAA is disabled. + */ + public int getGlMsaaLevel() { + return mGlMsaaLevel.get(); + } + + /** + * Sets the WebGL MSAA level. + * + * @param level number of MSAA samples, 0 if MSAA should be disabled. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setGlMsaaLevel(final int level) { + mGlMsaaLevel.commit(level); + return this; + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable RuntimeTelemetry.Delegate getTelemetryDelegate() { + return mTelemetryProxy.getDelegate(); + } + + /** + * Gets whether about:config is enabled or not. + * + * @return True if about:config is enabled, false otherwise. + */ + public boolean getAboutConfigEnabled() { + return mAboutConfig.get(); + } + + /** + * Sets whether or not about:config should be enabled. This is a page that allows users to + * directly modify Gecko preferences. Modification of some preferences may cause the app to break + * in unpredictable ways -- crashes, performance issues, security vulnerabilities, etc. + * + * @param flag True if about:config should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setAboutConfigEnabled(final boolean flag) { + mAboutConfig.commit(flag); + return this; + } + + /** + * Gets whether or not force user scalable zooming should be enabled or not. + * + * @return True if force user scalable zooming should be enabled, false otherwise. + */ + public boolean getForceUserScalableEnabled() { + return mForceUserScalable.get(); + } + + /** + * Sets whether or not pinch-zooming should be enabled when <code>user-scalable=no</code> is set + * on the viewport. + * + * @param flag True if force user scalable zooming should be enabled, false otherwise. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setForceUserScalableEnabled(final boolean flag) { + mForceUserScalable.commit(flag); + return this; + } + + /** + * Get whether login form autofill is enabled. + * + * @return True if login autofill is enabled. + */ + public boolean getLoginAutofillEnabled() { + return mAutofillLogins.get(); + } + + /** + * Set whether login forms should be filled automatically if only one viable candidate is provided + * via {@link Autocomplete.StorageDelegate#onLoginFetch onLoginFetch}. + * + * @param enabled A flag determining whether login autofill should be enabled. + * @return The builder instance. + */ + public @NonNull GeckoRuntimeSettings setLoginAutofillEnabled(final boolean enabled) { + mAutofillLogins.commit(enabled); + return this; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ALLOW_ALL, HTTPS_ONLY_PRIVATE, HTTPS_ONLY}) + public @interface HttpsOnlyMode {} + + /** Allow all insecure connections */ + public static final int ALLOW_ALL = 0; + /** Allow insecure connections in normal browsing, but only HTTPS in private browsing. */ + public static final int HTTPS_ONLY_PRIVATE = 1; + /** Only allow HTTPS connections. */ + public static final int HTTPS_ONLY = 2; + + /** + * Get whether and where insecure (non-HTTPS) connections are allowed. + * + * @return One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + */ + public @HttpsOnlyMode int getAllowInsecureConnections() { + final boolean httpsOnly = mHttpsOnly.get(); + final boolean httpsOnlyPrivate = mHttpsOnlyPrivateMode.get(); + if (httpsOnly) { + return HTTPS_ONLY; + } else if (httpsOnlyPrivate) { + return HTTPS_ONLY_PRIVATE; + } + return ALLOW_ALL; + } + + /** + * Set whether and where insecure (non-HTTPS) connections are allowed. + * + * @param level One of the {@link GeckoRuntimeSettings#ALLOW_ALL HttpsOnlyMode} constants. + * @return This GeckoRuntimeSettings instance. + */ + public @NonNull GeckoRuntimeSettings setAllowInsecureConnections(final @HttpsOnlyMode int level) { + switch (level) { + case ALLOW_ALL: + mHttpsOnly.commit(false); + mHttpsOnlyPrivateMode.commit(false); + break; + case HTTPS_ONLY_PRIVATE: + mHttpsOnly.commit(false); + mHttpsOnlyPrivateMode.commit(true); + break; + case HTTPS_ONLY: + mHttpsOnly.commit(true); + mHttpsOnlyPrivateMode.commit(false); + break; + default: + throw new IllegalArgumentException("Invalid setting for setAllowInsecureConnections"); + } + return this; + } + + // For internal use only + /* protected */ @NonNull + GeckoRuntimeSettings setProcessCount(final int processCount) { + mProcessCount.commit(processCount); + return this; + } + + @Override // Parcelable + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + + out.writeStringArray(mArgs); + mExtras.writeToParcel(out, flags); + ParcelableUtils.writeBoolean(out, mForceEnableAccessibility); + ParcelableUtils.writeBoolean(out, mDebugPause); + ParcelableUtils.writeBoolean(out, mUseMaxScreenDepth); + out.writeFloat(mDisplayDensityOverride); + out.writeInt(mDisplayDpiOverride); + out.writeInt(mScreenWidthOverride); + out.writeInt(mScreenHeightOverride); + out.writeString(mCrashHandler != null ? mCrashHandler.getName() : null); + out.writeStringArray(mRequestedLocales); + out.writeString(mConfigFilePath); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + super.readFromParcel(source); + + mArgs = source.createStringArray(); + mExtras.readFromParcel(source); + mForceEnableAccessibility = ParcelableUtils.readBoolean(source); + mDebugPause = ParcelableUtils.readBoolean(source); + mUseMaxScreenDepth = ParcelableUtils.readBoolean(source); + mDisplayDensityOverride = source.readFloat(); + mDisplayDpiOverride = source.readInt(); + mScreenWidthOverride = source.readInt(); + mScreenHeightOverride = source.readInt(); + + final String crashHandlerName = source.readString(); + if (crashHandlerName != null) { + try { + @SuppressWarnings("unchecked") + final Class<? extends Service> handler = + (Class<? extends Service>) Class.forName(crashHandlerName); + + mCrashHandler = handler; + } catch (final ClassNotFoundException e) { + } + } + + mRequestedLocales = source.createStringArray(); + mConfigFilePath = source.readString(); + } + + public static final Parcelable.Creator<GeckoRuntimeSettings> CREATOR = + new Parcelable.Creator<GeckoRuntimeSettings>() { + @Override + public GeckoRuntimeSettings createFromParcel(final Parcel in) { + final GeckoRuntimeSettings settings = new GeckoRuntimeSettings(); + settings.readFromParcel(in); + return settings; + } + + @Override + public GeckoRuntimeSettings[] newArray(final int size) { + return new GeckoRuntimeSettings[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java new file mode 100644 index 0000000000..746fce54f3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSession.java @@ -0,0 +1,6760 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.Matrix; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.Rect; +import android.graphics.RectF; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.IInterface; +import android.os.Parcel; +import android.os.Parcelable; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.Base64; +import android.util.Log; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewStructure; +import android.view.inputmethod.CursorAnchorInfo; +import android.view.inputmethod.ExtractedText; +import android.view.inputmethod.ExtractedTextRequest; +import android.widget.Magnifier; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringDef; +import androidx.annotation.UiThread; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.AbstractSequentialList; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.ListIterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.MagnifiableSurfaceView; +import org.mozilla.gecko.NativeQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.IntentUtils; +import org.mozilla.gecko.util.ThreadUtils; +import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo; + +public class GeckoSession { + private static final String LOGTAG = "GeckoSession"; + private static final boolean DEBUG = false; + + // Type of changes given to onWindowChanged. + // Window has been cleared due to the session being closed. + private static final int WINDOW_CLOSE = 0; + // Window has been set due to the session being opened. + private static final int WINDOW_OPEN = 1; // Window has been opened. + // Window has been cleared due to the session being transferred to another session. + private static final int WINDOW_TRANSFER_OUT = 2; // Window has been transfer. + // Window has been set due to another session being transferred to this one. + private static final int WINDOW_TRANSFER_IN = 3; + + private static final int DATA_URI_MAX_LENGTH = 2 * 1024 * 1024; + + // Delay running compositor memory pressure by 10s to avoid interfering with tab switching. + private static final int NOTIFY_MEMORY_PRESSURE_DELAY_MS = 10 * 1000; + + private final Runnable mNotifyMemoryPressure = + new Runnable() { + @Override + public void run() { + if (mCompositorReady) { + mCompositor.notifyMemoryPressure(); + } + } + }; + + private enum State implements NativeQueue.State { + INITIAL(0), + READY(1); + + private final int mRank; + + private State(final int rank) { + mRank = rank; + } + + @Override + public boolean is(final NativeQueue.State other) { + return this == other; + } + + @Override + public boolean isAtLeast(final NativeQueue.State other) { + return (other instanceof State) && mRank >= ((State) other).mRank; + } + } + + private final NativeQueue mNativeQueue = new NativeQueue(State.INITIAL, State.READY); + + private final EventDispatcher mEventDispatcher = new EventDispatcher(mNativeQueue); + + private final SessionTextInput mTextInput = new SessionTextInput(this, mNativeQueue); + private SessionAccessibility mAccessibility; + private SessionFinder mFinder; + + /** {@code SessionMagnifier} handles magnifying glass. */ + /* package */ interface SessionMagnifier { + /** + * Get the current {@link android.view.View} for magnifying glass. + * + * @return Current View for magnifying glass or null if not set. + */ + @UiThread + default @Nullable View getView() { + return null; + } + + /** + * Set the current {@link android.view.View} for magnifying glass. + * + * @param view View for magnifying glass or null to clear current View. + */ + @UiThread + default void setView(final @NonNull View view) {} + + /** + * Show magnifying glass. + * + * @param sourceCenter The source center of view that magnifying glass is attached + */ + @UiThread + default void show(final @NonNull PointF sourceCenter) {} + + /** Dismiss magnifying glass. */ + @UiThread + default void dismiss() {} + } + + @TargetApi(Build.VERSION_CODES.P) + private class SessionMagnifierP implements GeckoSession.SessionMagnifier { + private @Nullable View mView; + private @Nullable Magnifier mMagnifier; + private final @NonNull Compositor mCompositor; + + private SessionMagnifierP(final Compositor compositor) { + mCompositor = compositor; + } + + @Override + @UiThread + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + + return mView; + } + + @Override + @UiThread + public void setView(final @NonNull View view) { + ThreadUtils.assertOnUiThread(); + + if (mMagnifier != null) { + mMagnifier.dismiss(); + mMagnifier = null; + } + mView = view; + } + + @Override + @UiThread + public void show(final @NonNull PointF sourceCenter) { + ThreadUtils.assertOnUiThread(); + + if (mView == null) { + return; + } + if (mMagnifier == null) { + mMagnifier = new Magnifier(mView); + } + + if (mView instanceof MagnifiableSurfaceView) { + final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView; + view.setMagnifierSurface(mCompositor.getMagnifiableSurface()); + } + mMagnifier.show(sourceCenter.x, sourceCenter.y); + if (mView instanceof MagnifiableSurfaceView) { + final MagnifiableSurfaceView view = (MagnifiableSurfaceView) mView; + view.setMagnifierSurface(null); + } + } + + @Override + @UiThread + public void dismiss() { + ThreadUtils.assertOnUiThread(); + + if (mMagnifier == null) { + return; + } + + mMagnifier.dismiss(); + mMagnifier = null; + } + } + + private SessionMagnifier mMagnifier; + + private String mId; + /* package */ String getId() { + return mId; + } + + private boolean mShouldPinOnScreen; + + // All fields are accessed on UI thread only. + private PanZoomController mPanZoomController = new PanZoomController(this); + private OverscrollEdgeEffect mOverscroll; + private CompositorController mController; + private Autofill.Support mAutofillSupport; + + private boolean mAttachedCompositor; + private boolean mCompositorReady; + private SurfaceInfo mSurfaceInfo; + + // All fields of coordinates are in screen units. + private int mLeft; + private int mTop; // Top of the surface (including toolbar); + private int mClientTop; // Top of the client area (i.e. excluding toolbar); + private int mWidth; + private int mHeight; // Height of the surface (including toolbar); + private int mClientHeight; // Height of the client area (i.e. excluding toolbar); + private int mFixedBottomOffset = + 0; // The margin for fixed elements attached to the bottom of the viewport. + private int mDynamicToolbarMaxHeight = 0; // The maximum height of the dynamic toolbar + private float mViewportLeft; + private float mViewportTop; + private float mViewportZoom = 1.0f; + + // + // NOTE: These values are also defined in + // gfx/layers/ipc/UiCompositorControllerMessageTypes.h and must be kept in sync. Any + // new AnimatorMessageType added here must also be added there. + // + // Sent from compositor after first paint + /* package */ static final int FIRST_PAINT = 0; + // Sent from compositor when a layer has been updated + /* package */ static final int LAYERS_UPDATED = 1; + // Special message sent from UiCompositorControllerChild once it is open + /* package */ static final int COMPOSITOR_CONTROLLER_OPEN = 2; + // Special message sent from controller to query if the compositor controller is open. + /* package */ static final int IS_COMPOSITOR_CONTROLLER_OPEN = 3; + + /* protected */ class Compositor extends JNIObject { + public boolean isReady() { + return GeckoSession.this.isCompositorReady(); + } + + @WrapForJNI(calledFrom = "ui") + private void onCompositorAttached() { + GeckoSession.this.onCompositorAttached(); + } + + @WrapForJNI(calledFrom = "ui") + private void onCompositorDetached() { + // Clear out any pending calls on the UI thread. + GeckoSession.this.onCompositorDetached(); + } + + @WrapForJNI(dispatchTo = "gecko") + @Override + protected native void disposeNative(); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void attachNPZC(PanZoomController.NativeProvider npzc); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void onBoundsChanged(int left, int top, int width, int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void setDynamicToolbarMaxHeight(int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void notifyMemoryPressure(); + + // Gecko thread pauses compositor; blocks UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void syncPauseCompositor(); + + // UI thread resumes compositor and notifies Gecko thread; does not block UI thread. + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void syncResumeResizeCompositor( + int x, int y, int width, int height, Object surface, Object surfaceControl); + + // Returns a Surface that content has been rendered in to, which should be used when the + // magnifier is shown. This may differ from the Surface we have passed to + // syncResumeResizeCompositor(). + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native Surface getMagnifiableSurface(); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void setMaxToolbarHeight(int height); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void setFixedBottomOffset(int offset); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void sendToolbarAnimatorMessage(int message); + + @WrapForJNI(calledFrom = "ui") + private void recvToolbarAnimatorMessage(final int message) { + GeckoSession.this.handleCompositorMessage(message); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void setDefaultClearColor(int color); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + /* package */ native void requestScreenPixels( + final GeckoResult<Bitmap> result, + final Bitmap target, + final int x, + final int y, + final int srcWidth, + final int srcHeight, + final int outWidth, + final int outHeight); + + @WrapForJNI(calledFrom = "ui", dispatchTo = "current") + public native void enableLayerUpdateNotifications(boolean enable); + + // 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(calledFrom = "ui") + private void updateRootFrameMetrics( + final float scrollX, final float scrollY, final float zoom) { + GeckoSession.this.onMetricsChanged(scrollX, scrollY, zoom); + } + + @WrapForJNI(calledFrom = "ui") + private void updateOverscrollVelocity(final float x, final float y) { + GeckoSession.this.updateOverscrollVelocity(x, y); + } + + @WrapForJNI(calledFrom = "ui") + private void updateOverscrollOffset(final float x, final float y) { + GeckoSession.this.updateOverscrollOffset(x, y); + } + + @WrapForJNI(calledFrom = "ui", dispatchTo = "gecko") + public native void onSafeAreaInsetsChanged(int top, int right, int bottom, int left); + + @WrapForJNI(calledFrom = "ui") + public void setPointerIcon( + final int defaultCursor, final Bitmap customCursor, final float x, final float y) { + GeckoSession.this.setPointerIcon(defaultCursor, customCursor, x, y); + } + + @Override + protected void finalize() throws Throwable { + disposeNative(); + } + } + + /* package */ final Compositor mCompositor = new Compositor(); + + @WrapForJNI(stubName = "GetCompositor", calledFrom = "ui") + private Object getCompositorFromNative() { + // Only used by native code. + return mCompositorReady ? mCompositor : null; + } + + private final GeckoSessionHandler<HistoryDelegate> mHistoryHandler = + new GeckoSessionHandler<HistoryDelegate>( + "GeckoViewHistory", + this, + new String[] { + "GeckoView:OnVisited", "GeckoView:GetVisited", "GeckoView:StateUpdated", + }) { + @Override + public void handleMessage( + final HistoryDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + if ("GeckoView:OnVisited".equals(event)) { + final GeckoResult<Boolean> result = + delegate.onVisited( + GeckoSession.this, + message.getString("url"), + message.getString("lastVisitedURL"), + message.getInt("flags")); + + if (result == null) { + callback.sendSuccess(false); + return; + } + + result.accept( + visited -> callback.sendSuccess(visited.booleanValue()), + exception -> callback.sendSuccess(false)); + } else if ("GeckoView:GetVisited".equals(event)) { + final String[] urls = message.getStringArray("urls"); + + final GeckoResult<boolean[]> result = delegate.getVisited(GeckoSession.this, urls); + + if (result == null) { + callback.sendSuccess(null); + return; + } + + result.accept( + visited -> callback.sendSuccess(visited), + exception -> callback.sendError("Failed to fetch visited statuses for URIs")); + } else if ("GeckoView:StateUpdated".equals(event)) { + + final GeckoBundle update = message.getBundle("data"); + + if (update == null) { + return; + } + final int previousHistorySize = mStateCache.size(); + mStateCache.updateSessionState(update); + + final ProgressDelegate progressDelegate = getProgressDelegate(); + if (progressDelegate != null) { + final SessionState state = new SessionState(mStateCache); + if (!state.isEmpty()) { + progressDelegate.onSessionStateChange(GeckoSession.this, state); + } + } + + if (update.getBundle("historychange") != null) { + final SessionState state = new SessionState(mStateCache); + + delegate.onHistoryStateChange(GeckoSession.this, state); + + // If the previous history was larger than one entry and the new size is one, it means + // the + // History has been purged and the navigation delegate needs to be update. + if ((previousHistorySize > 1) + && (state.size() == 1) + && mNavigationHandler.getDelegate() != null) { + mNavigationHandler.getDelegate().onCanGoForward(GeckoSession.this, false); + mNavigationHandler.getDelegate().onCanGoBack(GeckoSession.this, false); + } + } + } + } + }; + + private final WebExtension.SessionController mWebExtensionController; + + private final GeckoSessionHandler<ContentDelegate> mContentHandler = + new GeckoSessionHandler<ContentDelegate>( + "GeckoViewContent", + this, + new String[] { + "GeckoView:ContentCrash", + "GeckoView:ContentKill", + "GeckoView:ContextMenu", + "GeckoView:DOMMetaViewportFit", + "GeckoView:PageTitleChanged", + "GeckoView:DOMWindowClose", + "GeckoView:ExternalResponse", + "GeckoView:FocusRequest", + "GeckoView:FullScreenEnter", + "GeckoView:FullScreenExit", + "GeckoView:WebAppManifest", + "GeckoView:FirstContentfulPaint", + "GeckoView:PaintStatusReset", + "GeckoView:PreviewImage", + "GeckoView:CookieBannerEvent:Detected", + "GeckoView:CookieBannerEvent:Handled", + }) { + @Override + public void handleMessage( + final ContentDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + if ("GeckoView:ContentCrash".equals(event)) { + close(); + delegate.onCrash(GeckoSession.this); + } else if ("GeckoView:ContentKill".equals(event)) { + close(); + delegate.onKill(GeckoSession.this); + } else if ("GeckoView:ContextMenu".equals(event)) { + final ContentDelegate.ContextElement elem = + new ContentDelegate.ContextElement( + message.getString("baseUri"), + message.getString("uri"), + message.getString("title"), + message.getString("alt"), + message.getString("elementType"), + message.getString("elementSrc")); + + delegate.onContextMenu( + GeckoSession.this, message.getInt("screenX"), message.getInt("screenY"), elem); + + } else if ("GeckoView:DOMMetaViewportFit".equals(event)) { + delegate.onMetaViewportFitChange(GeckoSession.this, message.getString("viewportfit")); + } else if ("GeckoView:PageTitleChanged".equals(event)) { + delegate.onTitleChange(GeckoSession.this, message.getString("title")); + } else if ("GeckoView:FocusRequest".equals(event)) { + delegate.onFocusRequest(GeckoSession.this); + } else if ("GeckoView:DOMWindowClose".equals(event)) { + delegate.onCloseRequest(GeckoSession.this); + } else if ("GeckoView:FullScreenEnter".equals(event)) { + delegate.onFullScreen(GeckoSession.this, true); + } else if ("GeckoView:FullScreenExit".equals(event)) { + delegate.onFullScreen(GeckoSession.this, false); + } else if ("GeckoView:WebAppManifest".equals(event)) { + final GeckoBundle manifest = message.getBundle("manifest"); + if (manifest == null) { + return; + } + + try { + delegate.onWebAppManifest( + GeckoSession.this, fixupWebAppManifest(manifest.toJSONObject())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Failed to convert web app manifest to JSON", e); + } + } else if ("GeckoView:FirstContentfulPaint".equals(event)) { + delegate.onFirstContentfulPaint(GeckoSession.this); + } else if ("GeckoView:PaintStatusReset".equals(event)) { + delegate.onPaintStatusReset(GeckoSession.this); + } else if ("GeckoView:PreviewImage".equals(event)) { + delegate.onPreviewImage(GeckoSession.this, message.getString("previewImageUrl")); + } else if ("GeckoView:CookieBannerEvent:Detected".equals(event)) { + delegate.onCookieBannerDetected(GeckoSession.this); + } else if ("GeckoView:CookieBannerEvent:Handled".equals(event)) { + delegate.onCookieBannerHandled(GeckoSession.this); + } + } + }; + + private final GeckoSessionHandler<NavigationDelegate> mNavigationHandler = + new GeckoSessionHandler<NavigationDelegate>( + "GeckoViewNavigation", + this, + new String[] {"GeckoView:LocationChange", "GeckoView:OnNewSession"}, + new String[] { + "GeckoView:OnLoadError", "GeckoView:OnLoadRequest", + }) { + // This needs to match nsIBrowserDOMWindow.idl + private int convertGeckoTarget(final int geckoTarget) { + switch (geckoTarget) { + case 0: // OPEN_DEFAULTWINDOW + case 1: // OPEN_CURRENTWINDOW + return NavigationDelegate.TARGET_WINDOW_CURRENT; + default: // OPEN_NEWWINDOW, OPEN_NEWTAB + return NavigationDelegate.TARGET_WINDOW_NEW; + } + } + + @Override + public void handleDefaultMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + + if ("GeckoView:OnLoadRequest".equals(event)) { + callback.sendSuccess(false); + } else if ("GeckoView:OnLoadError".equals(event)) { + callback.sendSuccess(null); + } else { + super.handleDefaultMessage(event, message, callback); + } + } + + // For .isOpen(), the linter is not smart enough to figure out we're asserting that we're on + // the UI thread. + @SuppressLint("WrongThread") + @Override + public void handleMessage( + final NavigationDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri")); + if ("GeckoView:LocationChange".equals(event)) { + if (message.getBoolean("isTopLevel")) { + final GeckoBundle[] perms = message.getBundleArray("permissions"); + final List<PermissionDelegate.ContentPermission> permList = + PermissionDelegate.ContentPermission.fromBundleArray(perms); + delegate.onLocationChange(GeckoSession.this, message.getString("uri"), permList); + } + delegate.onCanGoBack(GeckoSession.this, message.getBoolean("canGoBack")); + delegate.onCanGoForward(GeckoSession.this, message.getBoolean("canGoForward")); + } else if ("GeckoView:OnLoadRequest".equals(event)) { + final NavigationDelegate.LoadRequest request = + new NavigationDelegate.LoadRequest( + message.getString("uri"), + message.getString("triggerUri"), + message.getInt("where"), + message.getInt("flags"), + message.getBoolean("hasUserGesture"), + /* isDirectNavigation */ false); + + if (!IntentUtils.isUriSafeForScheme(request.uri)) { + callback.sendError("Blocked unsafe intent URI"); + + delegate.onLoadError( + GeckoSession.this, + request.uri, + new WebRequestError( + WebRequestError.ERROR_MALFORMED_URI, + WebRequestError.ERROR_CATEGORY_URI, + null)); + + return; + } + + final GeckoResult<AllowOrDeny> result = + delegate.onLoadRequest(GeckoSession.this, request); + + if (result == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo( + result.map( + value -> { + ThreadUtils.assertOnUiThread(); + if (value == AllowOrDeny.ALLOW) { + return false; + } + if (value == AllowOrDeny.DENY) { + return true; + } + throw new IllegalArgumentException("Invalid response"); + })); + } else if ("GeckoView:OnLoadError".equals(event)) { + final String uri = message.getString("uri"); + final long errorCode = message.getLong("error"); + final int errorModule = message.getInt("errorModule"); + final int errorClass = message.getInt("errorClass"); + + final WebRequestError err = + WebRequestError.fromGeckoError(errorCode, errorModule, errorClass, null); + + final GeckoResult<String> result = delegate.onLoadError(GeckoSession.this, uri, err); + if (result == null) { + callback.sendError("abort"); + return; + } + + callback.resolveTo( + result.map( + url -> { + if (url == null) { + throw new IllegalArgumentException("abort"); + } + return url; + })); + } else if ("GeckoView:OnNewSession".equals(event)) { + final String uri = message.getString("uri"); + final GeckoResult<GeckoSession> result = delegate.onNewSession(GeckoSession.this, uri); + if (result == null) { + callback.sendSuccess(false); + return; + } + + final String newSessionId = message.getString("newSessionId"); + callback.resolveTo( + result.map( + session -> { + ThreadUtils.assertOnUiThread(); + if (session == null) { + return false; + } + + if (session.isOpen()) { + throw new AssertionError("Must use an unopened GeckoSession instance"); + } + + if (GeckoSession.this.mWindow == null) { + throw new IllegalArgumentException("Session is not attached to a window"); + } + + session.open(GeckoSession.this.mWindow.runtime, newSessionId); + return true; + })); + } + } + }; + + private final GeckoSessionHandler<ContentDelegate> mProcessHangHandler = + new GeckoSessionHandler<ContentDelegate>( + "GeckoViewProcessHangMonitor", this, new String[] {"GeckoView:HangReport"}) { + + @Override + protected void handleMessage( + final ContentDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback eventCallback) { + Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri")); + + final GeckoResult<SlowScriptResponse> result = + delegate.onSlowScript(GeckoSession.this, message.getString("scriptFileName")); + if (result != null) { + final int mReportId = message.getInt("hangId"); + result.accept( + stopOrContinue -> { + if (stopOrContinue != null) { + final GeckoBundle bundle = new GeckoBundle(); + bundle.putInt("hangId", mReportId); + switch (stopOrContinue) { + case STOP: + mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle); + break; + case CONTINUE: + mEventDispatcher.dispatch("GeckoView:HangReportWait", bundle); + break; + } + } + }); + } else { + // default to stopping the script + final GeckoBundle bundle = new GeckoBundle(); + bundle.putInt("hangId", message.getInt("hangId")); + mEventDispatcher.dispatch("GeckoView:HangReportStop", bundle); + } + } + }; + + private final GeckoSessionHandler<ProgressDelegate> mProgressHandler = + new GeckoSessionHandler<ProgressDelegate>( + "GeckoViewProgress", + this, + new String[] { + "GeckoView:PageStart", + "GeckoView:PageStop", + "GeckoView:ProgressChanged", + "GeckoView:SecurityChanged", + "GeckoView:StateUpdated", + }) { + @Override + public void handleMessage( + final ProgressDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event + " uri=" + message.getString("uri")); + if ("GeckoView:PageStart".equals(event)) { + delegate.onPageStart(GeckoSession.this, message.getString("uri")); + } else if ("GeckoView:PageStop".equals(event)) { + delegate.onPageStop(GeckoSession.this, message.getBoolean("success")); + } else if ("GeckoView:ProgressChanged".equals(event)) { + delegate.onProgressChange(GeckoSession.this, message.getInt("progress")); + } else if ("GeckoView:SecurityChanged".equals(event)) { + final GeckoBundle identity = message.getBundle("identity"); + delegate.onSecurityChange( + GeckoSession.this, new ProgressDelegate.SecurityInformation(identity)); + } else if ("GeckoView:StateUpdated".equals(event)) { + final GeckoBundle update = message.getBundle("data"); + if (update != null) { + if (getHistoryDelegate() == null) { + mStateCache.updateSessionState(update); + final SessionState state = new SessionState(mStateCache); + if (!state.isEmpty()) { + delegate.onSessionStateChange(GeckoSession.this, state); + } + } + } + } + } + }; + + private final GeckoSessionHandler<ScrollDelegate> mScrollHandler = + new GeckoSessionHandler<ScrollDelegate>( + "GeckoViewScroll", this, new String[] {"GeckoView:ScrollChanged"}) { + @Override + public void handleMessage( + final ScrollDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + + if ("GeckoView:ScrollChanged".equals(event)) { + delegate.onScrollChanged( + GeckoSession.this, message.getInt("scrollX"), message.getInt("scrollY")); + } + } + }; + + private final GeckoSessionHandler<ContentBlocking.Delegate> mContentBlockingHandler = + new GeckoSessionHandler<ContentBlocking.Delegate>( + "GeckoViewContentBlocking", this, new String[] {"GeckoView:ContentBlockingEvent"}) { + @Override + public void handleMessage( + final ContentBlocking.Delegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + + if ("GeckoView:ContentBlockingEvent".equals(event)) { + final ContentBlocking.BlockEvent be = ContentBlocking.BlockEvent.fromBundle(message); + if (be.isBlocking()) { + delegate.onContentBlocked(GeckoSession.this, be); + } else { + delegate.onContentLoaded(GeckoSession.this, be); + } + } + } + }; + + private final GeckoSessionHandler<PermissionDelegate> mPermissionHandler = + new GeckoSessionHandler<PermissionDelegate>( + "GeckoViewPermission", + this, + new String[] { + "GeckoView:AndroidPermission", + "GeckoView:ContentPermission", + "GeckoView:MediaPermission" + }) { + @Override + public void handleMessage( + final PermissionDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage: " + event); + if (delegate == null) { + callback.sendSuccess(/* granted */ false); + return; + } + if ("GeckoView:AndroidPermission".equals(event)) { + delegate.onAndroidPermissionsRequest( + GeckoSession.this, + message.getStringArray("perms"), + new PermissionCallback("android", callback)); + } else if ("GeckoView:ContentPermission".equals(event)) { + final GeckoResult<Integer> res = + delegate.onContentPermissionRequest( + GeckoSession.this, new PermissionDelegate.ContentPermission(message)); + if (res == null) { + callback.sendSuccess(PermissionDelegate.ContentPermission.VALUE_PROMPT); + return; + } + + callback.resolveTo(res); + } else if ("GeckoView:MediaPermission".equals(event)) { + final GeckoBundle[] videoBundles = message.getBundleArray("video"); + final GeckoBundle[] audioBundles = message.getBundleArray("audio"); + PermissionDelegate.MediaSource[] videos = null; + PermissionDelegate.MediaSource[] audios = null; + + if (videoBundles != null) { + videos = new PermissionDelegate.MediaSource[videoBundles.length]; + for (int i = 0; i < videoBundles.length; i++) { + videos[i] = new PermissionDelegate.MediaSource(videoBundles[i]); + } + } + + if (audioBundles != null) { + audios = new PermissionDelegate.MediaSource[audioBundles.length]; + for (int i = 0; i < audioBundles.length; i++) { + audios[i] = new PermissionDelegate.MediaSource(audioBundles[i]); + } + } + + delegate.onMediaPermissionRequest( + GeckoSession.this, + message.getString("uri"), + videos, + audios, + new PermissionCallback("media", callback)); + } + } + }; + + private final GeckoSessionHandler<SelectionActionDelegate> mSelectionActionDelegate = + new GeckoSessionHandler<SelectionActionDelegate>( + "GeckoViewSelectionAction", + this, + new String[] { + "GeckoView:HideSelectionAction", + "GeckoView:ShowSelectionAction", + "GeckoView:HideMagnifier", + "GeckoView:ShowMagnifier", + "GeckoView:ClipboardPermissionRequest", + "GeckoView:DismissClipboardPermissionRequest", + }) { + @Override + public void handleMessage( + final SelectionActionDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + Log.d(LOGTAG, "handleMessage: " + event); + if ("GeckoView:ShowSelectionAction".equals(event)) { + final @SelectionActionDelegateAction HashSet<String> actionsSet = + new HashSet<>(Arrays.asList(message.getStringArray("actions"))); + final SelectionActionDelegate.Selection selection = + new SelectionActionDelegate.Selection(message, actionsSet, mEventDispatcher); + + delegate.onShowActionRequest(GeckoSession.this, selection); + + } else if ("GeckoView:HideSelectionAction".equals(event)) { + final String reasonString = message.getString("reason"); + final int reason; + if ("invisibleselection".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION; + } else if ("presscaret".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION; + } else if ("scroll".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL; + } else if ("visibilitychange".equals(reasonString)) { + reason = SelectionActionDelegate.HIDE_REASON_NO_SELECTION; + } else { + throw new IllegalArgumentException(); + } + + delegate.onHideAction(GeckoSession.this, reason); + } else if ("GeckoView:ShowMagnifier".equals(event)) { + final PointF point = message.getPointF("screenPoint"); + if (point == null) { + throw new IllegalArgumentException("Invalid argument"); + } + + // Magnifier is surface coordinate. + point.x -= GeckoSession.this.mLeft; + point.y -= GeckoSession.this.mClientTop; + GeckoSession.this.getMagnifier().show(point); + } else if ("GeckoView:HideMagnifier".equals(event)) { + GeckoSession.this.getMagnifier().dismiss(); + } else if ("GeckoView:ClipboardPermissionRequest".equals(event)) { + final SelectionActionDelegate.ClipboardPermission permission = + new SelectionActionDelegate.ClipboardPermission(message); + + final GeckoResult<AllowOrDeny> result = + delegate.onShowClipboardPermissionRequest(GeckoSession.this, permission); + callback.resolveTo( + result.map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return true; + } + if (value == AllowOrDeny.DENY) { + return false; + } + throw new IllegalArgumentException("Invalid response"); + })); + } else if ("GeckoView:DismissClipboardPermissionRequest".equals(event)) { + delegate.onDismissClipboardPermissionRequest(GeckoSession.this); + } + } + }; + + private final GeckoSessionHandler<MediaDelegate> mMediaHandler = + new GeckoSessionHandler<MediaDelegate>( + "GeckoViewMedia", + this, + new String[] { + "GeckoView:MediaRecordingStatusChanged", + }) { + @Override + public void handleMessage( + final MediaDelegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + if ("GeckoView:MediaRecordingStatusChanged".equals(event)) { + final GeckoBundle[] deviceBundles = message.getBundleArray("devices"); + final MediaDelegate.RecordingDevice[] devices = + new MediaDelegate.RecordingDevice[deviceBundles.length]; + for (int i = 0; i < deviceBundles.length; i++) { + devices[i] = new MediaDelegate.RecordingDevice(deviceBundles[i]); + } + delegate.onRecordingStatusChanged(GeckoSession.this, devices); + return; + } + } + }; + + private final MediaSession.Handler mMediaSessionHandler = new MediaSession.Handler(this); + + /* package */ int handlersCount; + + private final GeckoSessionHandler<?>[] mSessionHandlers = + new GeckoSessionHandler<?>[] { + mContentHandler, mHistoryHandler, mMediaHandler, + mNavigationHandler, mPermissionHandler, mProcessHangHandler, + mProgressHandler, mScrollHandler, mSelectionActionDelegate, + mContentBlockingHandler, mMediaSessionHandler + }; + + private static class PermissionCallback + implements PermissionDelegate.Callback, PermissionDelegate.MediaCallback { + + private final String mType; + private EventCallback mCallback; + + public PermissionCallback(final String type, final EventCallback callback) { + mType = type; + mCallback = callback; + } + + private void submit(final Object response) { + if (mCallback != null) { + mCallback.sendSuccess(response); + mCallback = null; + } + } + + @Override // PermissionDelegate.Callback + public void grant() { + if ("media".equals(mType)) { + throw new UnsupportedOperationException(); + } + submit(/* response */ true); + } + + @Override // PermissionDelegate.Callback, PermissionDelegate.MediaCallback + public void reject() { + submit(/* response */ false); + } + + @Override // PermissionDelegate.MediaCallback + public void grant(final String video, final String audio) { + if (!"media".equals(mType)) { + throw new UnsupportedOperationException(); + } + final GeckoBundle response = new GeckoBundle(2); + response.putString("video", video); + response.putString("audio", audio); + submit(response); + } + + @Override // PermissionDelegate.MediaCallback + public void grant( + final PermissionDelegate.MediaSource video, final PermissionDelegate.MediaSource audio) { + grant(video != null ? video.id : null, audio != null ? audio.id : null); + } + } + + /** + * Get the current user agent string for this GeckoSession. + * + * @return a {@link GeckoResult} containing the UserAgent string + */ + @AnyThread + public @NonNull GeckoResult<String> getUserAgent() { + return mEventDispatcher.queryString("GeckoView:GetUserAgent"); + } + + /** + * Get the default user agent for this GeckoView build. + * + * <p>This method does not account for any override that might have been applied to the user agent + * string. + * + * @return the default user agent string + */ + @AnyThread + public static @NonNull String getDefaultUserAgent() { + return BuildConfig.USER_AGENT_GECKOVIEW_MOBILE; + } + + /** + * Get the current permission delegate for this GeckoSession. + * + * @return PermissionDelegate instance or null if using default delegate. + */ + @UiThread + public @Nullable PermissionDelegate getPermissionDelegate() { + ThreadUtils.assertOnUiThread(); + return mPermissionHandler.getDelegate(); + } + + /** + * Set the current permission delegate for this GeckoSession. + * + * @param delegate PermissionDelegate instance or null to use the default delegate. + */ + @UiThread + public void setPermissionDelegate(final @Nullable PermissionDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mPermissionHandler.setDelegate(delegate, this); + } + + private PromptDelegate mPromptDelegate; + + private final Listener mListener = new Listener(); + + /* package */ static final class Window extends JNIObject implements IInterface { + public final GeckoRuntime runtime; + private WeakReference<GeckoSession> mOwner; + private NativeQueue mNativeQueue; + private Binder mBinder; + + public Window( + final @NonNull GeckoRuntime runtime, + final @NonNull GeckoSession owner, + final @NonNull NativeQueue nativeQueue) { + this.runtime = runtime; + mOwner = new WeakReference<>(owner); + mNativeQueue = nativeQueue; + } + + @Override // IInterface + public Binder asBinder() { + if (mBinder == null) { + mBinder = new Binder(); + mBinder.attachInterface(this, Window.class.getName()); + } + return mBinder; + } + + // Create a new Gecko window and assign an initial set of Java session objects to it. + @WrapForJNI(dispatchTo = "proxy") + public static native void open( + Window instance, + NativeQueue queue, + Compositor compositor, + EventDispatcher dispatcher, + SessionAccessibility.NativeProvider sessionAccessibility, + GeckoBundle initData, + String id, + String chromeUri, + boolean privateMode); + + @Override // JNIObject + public void disposeNative() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeDisposeNative(); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, this, "nativeDisposeNative"); + } + } + + @WrapForJNI(dispatchTo = "proxy", stubName = "DisposeNative") + private native void nativeDisposeNative(); + + // Force the underlying Gecko window to close and release assigned Java objects. + public void close() { + // Reset our queue, so we don't end up with queued calls on a disposed object. + synchronized (this) { + if (mNativeQueue == null) { + // Already closed elsewhere. + return; + } + mNativeQueue.reset(State.INITIAL); + mNativeQueue = null; + mOwner = new WeakReference<>(null); + } + + // Detach ourselves from the binder as well, to prevent this window from being + // read from any parcels. + asBinder().attachInterface(null, Window.class.getName()); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeClose(); + } else { + GeckoThread.queueNativeCallUntil(GeckoThread.State.PROFILE_READY, this, "nativeClose"); + } + } + + @WrapForJNI(dispatchTo = "proxy", stubName = "Close") + private native void nativeClose(); + + @WrapForJNI(dispatchTo = "proxy", stubName = "Transfer") + private native void nativeTransfer( + NativeQueue queue, + Compositor compositor, + EventDispatcher dispatcher, + SessionAccessibility.NativeProvider sessionAccessibility, + GeckoBundle initData); + + @WrapForJNI(dispatchTo = "proxy") + public native void attachEditable(IGeckoEditableParent parent); + + @WrapForJNI(dispatchTo = "proxy") + public native void attachAccessibility( + SessionAccessibility.NativeProvider sessionAccessibility); + + @WrapForJNI(dispatchTo = "proxy") + public native void printToPdf(GeckoResult<InputStream> geckoResult); + + @WrapForJNI(calledFrom = "gecko") + private synchronized void onReady(final @Nullable NativeQueue queue) { + // onReady is called the first time the Gecko window is ready, with a null queue + // argument. In this case, we simply set the current queue to ready state. + // + // After the initial call, onReady is called again every time Window.transfer() + // is called, with a non-null queue argument. In this case, we only set the + // current queue to ready state _if_ the current queue matches the given queue, + // because if the queues don't match, we know there is another onReady call coming. + + if ((queue == null && mNativeQueue == null) || (queue != null && mNativeQueue != queue)) { + return; + } + + if (mNativeQueue.checkAndSetState(State.INITIAL, State.READY) && queue == null) { + Log.i(LOGTAG, "zerdatime " + SystemClock.elapsedRealtime() + " - chrome startup finished"); + } + } + + @Override + protected void finalize() throws Throwable { + close(); + disposeNative(); + } + + @WrapForJNI(calledFrom = "gecko") + private GeckoResult<Boolean> onLoadRequest( + final @NonNull String uri, + final int windowType, + final int flags, + final @Nullable String triggeringUri, + final boolean hasUserGesture, + final boolean isTopLevel) { + final ProfilerController profilerController = runtime.getProfilerController(); + final Double onLoadRequestProfilerStartTime = profilerController.getProfilerTime(); + final Runnable addMarker = + () -> + profilerController.addMarker( + "GeckoSession.onLoadRequest", onLoadRequestProfilerStartTime); + + final GeckoSession session = mOwner.get(); + if (session == null) { + // Don't handle any load request if we can't get the session for some reason. + return GeckoResult.fromValue(false); + } + final GeckoResult<Boolean> res = new GeckoResult<>(); + + ThreadUtils.postToUiThread( + new Runnable() { + @Override + public void run() { + final NavigationDelegate delegate = session.getNavigationDelegate(); + + if (delegate == null) { + res.complete(false); + addMarker.run(); + return; + } + + if (!IntentUtils.isUriSafeForScheme(uri)) { + delegate.onLoadError( + session, + uri, + new WebRequestError( + WebRequestError.ERROR_MALFORMED_URI, + WebRequestError.ERROR_CATEGORY_URI, + null)); + res.complete(true); + addMarker.run(); + return; + } + + final String trigger = TextUtils.isEmpty(triggeringUri) ? null : triggeringUri; + final NavigationDelegate.LoadRequest req = + new NavigationDelegate.LoadRequest( + uri, + trigger, + windowType, + flags, + hasUserGesture, + false /* isDirectNavigation */); + final GeckoResult<AllowOrDeny> reqResponse = + isTopLevel + ? delegate.onLoadRequest(session, req) + : delegate.onSubframeLoadRequest(session, req); + + if (reqResponse == null) { + res.complete(false); + addMarker.run(); + return; + } + + reqResponse.accept( + value -> { + if (value == AllowOrDeny.DENY) { + res.complete(true); + } else { + res.complete(false); + } + addMarker.run(); + }, + ex -> { + // This is incredibly ugly and unreadable because checkstyle sucks. + res.complete(false); + addMarker.run(); + }); + } + }); + + return res; + } + + @WrapForJNI(calledFrom = "ui") + private void passExternalWebResponse(final WebResponse response) { + final GeckoSession session = mOwner.get(); + if (session == null) { + return; + } + final ContentDelegate delegate = session.getContentDelegate(); + if (delegate != null) { + delegate.onExternalResponse(session, response); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void onShowDynamicToolbar() { + final Window self = this; + ThreadUtils.runOnUiThread( + () -> { + final GeckoSession session = self.mOwner.get(); + if (session == null) { + return; + } + final ContentDelegate delegate = session.getContentDelegate(); + if (delegate != null) { + delegate.onShowDynamicToolbar(session); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private void onUpdateSessionStore(final GeckoBundle aBundle) { + ThreadUtils.runOnUiThread( + () -> { + final GeckoSession session = mOwner.get(); + if (session == null) { + return; + } + GeckoBundle scroll = aBundle.getBundle("scroll"); + if (scroll == null) { + scroll = new GeckoBundle(); + aBundle.putBundle("scroll", scroll); + } + + // Here we unfortunately need to do some re-mapping since `zoom` is passed in a separate + // bunds and we wish to keep the bundle format. + scroll.putBundle("zoom", aBundle.getBundle("zoom")); + final SessionState stateCache = session.mStateCache; + stateCache.updateSessionState(aBundle); + final SessionState state = new SessionState(stateCache); + if (!state.isEmpty()) { + final ProgressDelegate progressDelegate = session.getProgressDelegate(); + if (progressDelegate != null) { + progressDelegate.onSessionStateChange(session, state); + } else { + } + } + }); + } + } + + private class Listener implements BundleEventListener { + /* package */ void registerListeners() { + getEventDispatcher() + .registerUiThreadListener( + this, + "GeckoView:PinOnScreen", + "GeckoView:Prompt", + "GeckoView:Prompt:Dismiss", + "GeckoView:Prompt:Update", + null); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleMessage " + event); + + if ("GeckoView:PinOnScreen".equals(event)) { + GeckoSession.this.setShouldPinOnScreen(message.getBoolean("pinned")); + } else if ("GeckoView:Prompt".equals(event)) { + mPromptController.handleEvent(GeckoSession.this, message.getBundle("prompt"), callback); + } else if ("GeckoView:Prompt:Dismiss".equals(event)) { + mPromptController.dismissPrompt(message.getString("id")); + } else if ("GeckoView:Prompt:Update".equals(event)) { + mPromptController.updatePrompt(message.getBundle("prompt")); + } + } + } + + private final PromptController mPromptController; + + protected @Nullable Window mWindow; + private GeckoSessionSettings mSettings; + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSession() { + this(null); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSession(final @Nullable GeckoSessionSettings settings) { + mSettings = new GeckoSessionSettings(settings, this); + mListener.registerListeners(); + + mWebExtensionController = new WebExtension.SessionController(this); + mPromptController = new PromptController(); + + mAutofillSupport = new Autofill.Support(this); + mAutofillSupport.registerListeners(); + + if (BuildConfig.DEBUG_BUILD && handlersCount != mSessionHandlers.length) { + throw new AssertionError("Add new handler to handlers list"); + } + } + + /* package */ @Nullable + GeckoRuntime getRuntime() { + if (mWindow == null) { + return null; + } + return mWindow.runtime; + } + + /* package */ synchronized void abandonWindow() { + if (mWindow == null) { + return; + } + + onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ true); + mWindow = null; + onWindowChanged(WINDOW_TRANSFER_OUT, /* inProgress */ false); + } + + /** + * Return whether this session is open. + * + * @return True if session is open. + * @see #open + * @see #close + */ + @UiThread + public boolean isOpen() { + ThreadUtils.assertOnUiThread(); + return mWindow != null; + } + + /* package */ boolean isReady() { + return mNativeQueue.isReady(); + } + + private GeckoBundle createInitData() { + final GeckoBundle initData = new GeckoBundle(2); + initData.putBundle("settings", mSettings.toBundle()); + + final GeckoBundle modules = new GeckoBundle(mSessionHandlers.length); + for (final GeckoSessionHandler<?> handler : mSessionHandlers) { + modules.putBoolean(handler.getName(), handler.isEnabled()); + } + initData.putBundle("modules", modules); + return initData; + } + + /** + * Opens the session. + * + * <p>Call this when you are ready to use a GeckoSession instance. + * + * <p>The session is in a 'closed' state when first created. Opening it creates the underlying + * Gecko objects necessary to load a page, etc. Most GeckoSession methods only take affect on an + * open session, and are queued until the session is opened here. Opening a session is an + * asynchronous operation. + * + * @param runtime The Gecko runtime to attach this session to. + * @see #close + * @see #isOpen + */ + @UiThread + public void open(final @NonNull GeckoRuntime runtime) { + open(runtime, UUID.randomUUID().toString().replace("-", "")); + } + + /* package */ void open(final @NonNull GeckoRuntime runtime, final String id) { + ThreadUtils.assertOnUiThread(); + + if (isOpen()) { + // We will leak the existing Window if we open another one. + throw new IllegalStateException("Session is open"); + } + + final String chromeUri = mSettings.getChromeUri(); + final boolean isPrivate = mSettings.getUsePrivateMode(); + + mId = id; + mWindow = new Window(runtime, this, mNativeQueue); + mWebExtensionController.setRuntime(runtime); + + onWindowChanged(WINDOW_OPEN, /* inProgress */ true); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + Window.open( + mWindow, + mNativeQueue, + mCompositor, + mEventDispatcher, + mAccessibility != null ? mAccessibility.nativeProvider : null, + createInitData(), + mId, + chromeUri, + isPrivate); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + Window.class, + "open", + Window.class, + mWindow, + NativeQueue.class, + mNativeQueue, + Compositor.class, + mCompositor, + EventDispatcher.class, + mEventDispatcher, + SessionAccessibility.NativeProvider.class, + mAccessibility != null ? mAccessibility.nativeProvider : null, + GeckoBundle.class, + createInitData(), + String.class, + mId, + String.class, + chromeUri, + isPrivate); + } + + onWindowChanged(WINDOW_OPEN, /* inProgress */ false); + } + + /** + * Closes the session. + * + * <p>This frees the underlying Gecko objects and unloads the current page. The session may be + * reopened later, but page state is not restored. Call this when you are finished using a + * GeckoSession instance. + * + * @see #open + * @see #isOpen + */ + @UiThread + public void close() { + ThreadUtils.assertOnUiThread(); + + if (!isOpen()) { + Log.w(LOGTAG, "Attempted to close a GeckoSession that was already closed."); + return; + } + + onWindowChanged(WINDOW_CLOSE, /* inProgress */ true); + + // We need to ensure the compositor releases any Surface it currently holds. + onSurfaceDestroyed(); + + mWindow.close(); + mWindow.disposeNative(); + // Can't access the compositor after we dispose of the window + mCompositorReady = false; + mWindow = null; + + onWindowChanged(WINDOW_CLOSE, /* inProgress */ false); + } + + private void onWindowChanged(final int change, final boolean inProgress) { + if ((change == WINDOW_OPEN || change == WINDOW_TRANSFER_IN) && !inProgress) { + mTextInput.onWindowChanged(mWindow); + } + if ((change == WINDOW_CLOSE || change == WINDOW_TRANSFER_OUT) && !inProgress) { + getAutofillSupport().clear(); + } + } + + /** + * Get the SessionTextInput instance for this session. May be called on any thread. + * + * @return SessionTextInput instance. + */ + @AnyThread + public @NonNull SessionTextInput getTextInput() { + // May be called on any thread. + return mTextInput; + } + + /** + * Get the SessionAccessibility instance for this session. + * + * @return SessionAccessibility instance. + */ + @UiThread + public @NonNull SessionAccessibility getAccessibility() { + ThreadUtils.assertOnUiThread(); + if (mAccessibility != null) { + return mAccessibility; + } + + mAccessibility = new SessionAccessibility(this); + if (mWindow != null) { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + mWindow.attachAccessibility(mAccessibility.nativeProvider); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + mWindow, + "attachAccessibility", + SessionAccessibility.NativeProvider.class, + mAccessibility.nativeProvider); + } + } + return mAccessibility; + } + + /** + * Get the SessionMagnifier instance for this session. + * + * @return SessionMagnifier instance. + */ + @UiThread + /* package */ @NonNull + SessionMagnifier getMagnifier() { + ThreadUtils.assertOnUiThread(); + if (mMagnifier == null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + mMagnifier = new SessionMagnifierP(mCompositor); + } else { + mMagnifier = new SessionMagnifier() {}; + } + } + + return mMagnifier; + } + + // The priority of the GeckoSession, either default or high. + @Retention(RetentionPolicy.SOURCE) + @IntDef({PRIORITY_DEFAULT, PRIORITY_HIGH}) + public @interface Priority {} + + /** Value for Priority when it is default. */ + public static final int PRIORITY_DEFAULT = 0; + + /** Value for Priority when it is high. */ + public static final int PRIORITY_HIGH = 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + LOAD_FLAGS_NONE, + LOAD_FLAGS_BYPASS_CACHE, + LOAD_FLAGS_BYPASS_PROXY, + LOAD_FLAGS_EXTERNAL, + LOAD_FLAGS_ALLOW_POPUPS, + LOAD_FLAGS_FORCE_ALLOW_DATA_URI, + LOAD_FLAGS_REPLACE_HISTORY + }) + public @interface LoadFlags {} + + // These flags follow similarly named ones in Gecko's nsIWebNavigation.idl + // https://searchfox.org/mozilla-central/source/docshell/base/nsIWebNavigation.idl + // + // We do not use the same values directly in order to insulate ourselves from + // changes in Gecko. Instead, the flags are converted in GeckoViewNavigation.jsm. + + /** Default load flag, no special considerations. */ + public static final int LOAD_FLAGS_NONE = 0; + + /** Bypass the cache. */ + public static final int LOAD_FLAGS_BYPASS_CACHE = 1 << 0; + + /** Bypass the proxy, if one has been configured. */ + public static final int LOAD_FLAGS_BYPASS_PROXY = 1 << 1; + + /** The load is coming from an external app. Perform additional checks. */ + public static final int LOAD_FLAGS_EXTERNAL = 1 << 2; + + /** Popup blocking will be disabled for this load */ + public static final int LOAD_FLAGS_ALLOW_POPUPS = 1 << 3; + + /** Bypass the URI classifier (content blocking and Safe Browsing). */ + public static final int LOAD_FLAGS_BYPASS_CLASSIFIER = 1 << 4; + + /** + * Allows a top-level data: navigation to occur. E.g. view-image is an explicit user action which + * should be allowed. + */ + public static final int LOAD_FLAGS_FORCE_ALLOW_DATA_URI = 1 << 5; + + /** This flag specifies that any existing history entry should be replaced. */ + public static final int LOAD_FLAGS_REPLACE_HISTORY = 1 << 6; + + /** + * Filter headers according to the CORS safelisted rules. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header"> + * CORS-safelisted request header </a>. + */ + public static final int HEADER_FILTER_CORS_SAFELISTED = 1; + /** + * Allows most headers. + * + * <p>Note: the <code>Host</code> and <code>Connection</code> headers are still ignored. + * + * <p>This should only be used when input is hard-coded from the app or when properly sanitized, + * as some headers could cause unexpected consequences and security issues. + * + * <p>Only use this if you know what you're doing. + */ + public static final int HEADER_FILTER_UNRESTRICTED_UNSAFE = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef(value = {HEADER_FILTER_CORS_SAFELISTED, HEADER_FILTER_UNRESTRICTED_UNSAFE}) + public @interface HeaderFilter {} + + /** + * Main entry point for loading URIs into a {@link GeckoSession}. + * + * <p>The simplest use case is loading a URIs with no extra options, this can be accomplished by + * specifying the URI in {@link #uri} and then calling {@link #load}, e.g. + * + * <pre><code> + * session.load(new Loader().uri("http://mozilla.org")); + * </code></pre> + * + * This class can also be used to load <code>data:</code> URIs, either from a <code>byte[]</code> + * array or a <code>String</code> using {@link #data}, e.g. + * + * <pre><code> + * session.load(new Loader().data("the data:1234,5678", "text/plain")); + * </code></pre> + * + * This class also allows you to specify some extra data, e.g. you can set a referrer using {@link + * #referrer} which can either be a {@link GeckoSession} or a plain URL string. You can also + * specify some Load Flags using {@link #flags}. + * + * <p>The class is structured as a Builder, so method calls can be easily chained, e.g. + * + * <pre><code> + * session.load(new Loader() + * .url("http://mozilla.org") + * .referrer("http://my-referrer.com") + * .flags(...)); + * </code></pre> + */ + @AnyThread + public static class Loader { + private String mUri; + private GeckoSession mReferrerSession; + private String mReferrerUri; + private GeckoBundle mHeaders; + private @LoadFlags int mLoadFlags = LOAD_FLAGS_NONE; + private boolean mIsDataUri; + private @HeaderFilter int mHeaderFilter = HEADER_FILTER_CORS_SAFELISTED; + + private static @NonNull String createDataUri( + @NonNull final byte[] bytes, @Nullable final String mimeType) { + return String.format( + "data:%s;base64,%s", + mimeType != null ? mimeType : "", Base64.encodeToString(bytes, Base64.NO_WRAP)); + } + + private static @NonNull String createDataUri( + @NonNull final String data, @Nullable final String mimeType) { + return String.format("data:%s,%s", mimeType != null ? mimeType : "", data); + } + + @Override + public int hashCode() { + // Move to Objects.hashCode once our MIN_SDK >= 19 + return Arrays.hashCode( + new Object[] { + mUri, mReferrerSession, mReferrerUri, mHeaders, mLoadFlags, mIsDataUri, mHeaderFilter + }); + } + + private static boolean equals(final Object a, final Object b) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return Objects.equals(a, b); + } + + return (a == b) || (a != null && a.equals(b)); + } + + @Override + public boolean equals(final @Nullable Object obj) { + if (!(obj instanceof Loader)) { + return false; + } + + final Loader other = (Loader) obj; + return equals(mUri, other.mUri) + && equals(mReferrerSession, other.mReferrerSession) + && equals(mReferrerUri, other.mReferrerUri) + && equals(mHeaders, other.mHeaders) + && equals(mLoadFlags, other.mLoadFlags) + && equals(mIsDataUri, other.mIsDataUri) + && equals(mHeaderFilter, other.mHeaderFilter); + } + + /** + * Set the URI of the resource to load. + * + * @param uri a String containg the URI + * @return this {@link Loader} instance. + */ + @NonNull + public Loader uri(final @NonNull String uri) { + mUri = uri; + mIsDataUri = false; + return this; + } + + /** + * Set the URI of the resource to load. + * + * @param uri a {@link Uri} instance + * @return this {@link Loader} instance. + */ + @NonNull + public Loader uri(final @NonNull Uri uri) { + mUri = uri.toString(); + mIsDataUri = false; + return this; + } + + /** + * Set the data URI of the resource to load. + * + * @param bytes a <code>byte</code> array containing the data to load. + * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g. + * "text/plain" + * @return this {@link Loader} instance. + */ + @NonNull + public Loader data(final @NonNull byte[] bytes, final @Nullable String mimeType) { + mUri = createDataUri(bytes, mimeType); + mIsDataUri = true; + return this; + } + + /** + * Set the data URI of the resource to load. + * + * @param data a <code>String</code> array containing the data to load. + * @param mimeType a <code>String</code> containing the mime type for this data URI, e.g. + * "text/plain" + * @return this {@link Loader} instance. + */ + @NonNull + public Loader data(final @NonNull String data, final @Nullable String mimeType) { + mUri = createDataUri(data, mimeType); + mIsDataUri = true; + return this; + } + + /** + * Set the referrer for this load. + * + * @param referrer a <code>GeckoSession</code> that will be used as the referrer + * @return this {@link Loader} instance. + */ + @NonNull + public Loader referrer(final @NonNull GeckoSession referrer) { + mReferrerSession = referrer; + return this; + } + + /** + * Set the referrer for this load. + * + * @param referrerUri a {@link Uri} that will be used as the referrer + * @return this {@link Loader} instance. + */ + @NonNull + public Loader referrer(final @NonNull Uri referrerUri) { + mReferrerUri = referrerUri != null ? referrerUri.toString() : null; + return this; + } + + /** + * Set the referrer for this load. + * + * @param referrerUri a <code>String</code> containing the URI that will be used as the referrer + * @return this {@link Loader} instance. + */ + @NonNull + public Loader referrer(final @NonNull String referrerUri) { + mReferrerUri = referrerUri; + return this; + } + + /** + * Add headers for this load. + * + * <p>Note: only CORS safelisted headers are allowed by default. To modify this behavior use + * {@link #headerFilter}. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_request_header"> + * CORS-safelisted request header </a>. + * + * @param headers a <code>Map</code> containing headers that will be added to this load. + * @return this {@link Loader} instance. + */ + @NonNull + public Loader additionalHeaders(final @NonNull Map<String, String> headers) { + final GeckoBundle bundle = new GeckoBundle(headers.size()); + for (final Map.Entry<String, String> entry : headers.entrySet()) { + if (entry.getKey() == null) { + // Ignore null keys + continue; + } + bundle.putString(entry.getKey(), entry.getValue()); + } + mHeaders = bundle; + return this; + } + + /** + * Modify the header filter behavior. By default only CORS safelisted headers are allowed. + * + * @param filter one of the {@link GeckoSession#HEADER_FILTER_CORS_SAFELISTED HEADER_FILTER_*} + * constants. + * @return this {@link Loader} instance. + */ + @NonNull + public Loader headerFilter(final @HeaderFilter int filter) { + mHeaderFilter = filter; + return this; + } + + /** + * Set the load flags for this load. + * + * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*} + * that will be used as the referrer + * @return this {@link Loader} instance. + */ + @NonNull + public Loader flags(final @LoadFlags int flags) { + mLoadFlags = flags; + return this; + } + } + + /** + * Load page using the {@link Loader} specified. + * + * @param request Loader for this request. + * @see Loader + */ + @AnyThread + public void load(final @NonNull Loader request) { + if (request.mUri == null) { + throw new IllegalArgumentException( + "You need to specify at least one between `uri` and `data`."); + } + + if (request.mReferrerUri != null && request.mReferrerSession != null) { + throw new IllegalArgumentException( + "Cannot specify both a referrer session and a referrer URI."); + } + + final NavigationDelegate navDelegate = mNavigationHandler.getDelegate(); + final boolean isDataUriTooLong = !maybeCheckDataUriLength(request); + if (navDelegate == null && isDataUriTooLong) { + throw new IllegalArgumentException("data URI is too long"); + } + + final int loadFlags = + request.mIsDataUri + // If this is a data: load then we need to force allow it. + ? request.mLoadFlags | LOAD_FLAGS_FORCE_ALLOW_DATA_URI + : request.mLoadFlags; + + // For performance reasons we short-circuit the delegate here + // instead of making Gecko call it for direct load calls. + final NavigationDelegate.LoadRequest loadRequest = + new NavigationDelegate.LoadRequest( + request.mUri, + null, /* triggerUri */ + 1, /* geckoTarget: OPEN_CURRENTWINDOW */ + 0, /* flags */ + false, /* hasUserGesture */ + true /* isDirectNavigation */); + + shouldLoadUri(loadRequest) + .getOrAccept( + allowOrDeny -> { + if (allowOrDeny == AllowOrDeny.DENY) { + return; + } + + if (isDataUriTooLong) { + ThreadUtils.runOnUiThread( + () -> { + navDelegate.onLoadError( + this, + request.mUri, + new WebRequestError( + WebRequestError.ERROR_DATA_URI_TOO_LONG, + WebRequestError.ERROR_CATEGORY_URI, + null)); + }); + return; + } + + final GeckoBundle msg = new GeckoBundle(); + msg.putString("uri", request.mUri); + msg.putInt("flags", loadFlags); + msg.putInt("headerFilter", request.mHeaderFilter); + + if (request.mReferrerUri != null) { + msg.putString("referrerUri", request.mReferrerUri); + } + + if (request.mReferrerSession != null) { + msg.putString("referrerSessionId", request.mReferrerSession.mId); + } + + if (request.mHeaders != null) { + msg.putBundle("headers", request.mHeaders); + } + + mEventDispatcher.dispatch("GeckoView:LoadUri", msg); + }); + } + + /** + * Load the given URI. + * + * <p>Convenience method for + * + * <pre><code> + * session.load(new Loader().uri(uri)); + * </code></pre> + * + * @param uri The URI of the resource to load. + */ + @AnyThread + public void loadUri(final @NonNull String uri) { + load(new Loader().uri(uri)); + } + + private GeckoResult<AllowOrDeny> shouldLoadUri(final NavigationDelegate.LoadRequest request) { + final NavigationDelegate delegate = mNavigationHandler.getDelegate(); + if (delegate == null) { + return GeckoResult.allow(); + } + + // Always run the callback on the UI thread regardless of what thread we were called in. + final GeckoResult<AllowOrDeny> result = new GeckoResult<>(ThreadUtils.getUiHandler()); + + ThreadUtils.runOnUiThread( + () -> { + final GeckoResult<AllowOrDeny> delegateResult = delegate.onLoadRequest(this, request); + + if (delegateResult == null) { + result.complete(AllowOrDeny.ALLOW); + } else { + delegateResult.getOrAccept( + allowOrDeny -> result.complete(allowOrDeny), + error -> result.completeExceptionally(error)); + } + }); + + return result; + } + + /** Reload the current URI. */ + @AnyThread + public void reload() { + reload(LOAD_FLAGS_NONE); + } + + /** + * Reload the current URI. + * + * @param flags the load flags to use, an OR-ed value of {@link #LOAD_FLAGS_NONE LOAD_FLAGS_*} + */ + @AnyThread + public void reload(final @LoadFlags int flags) { + final GeckoBundle msg = new GeckoBundle(); + msg.putInt("flags", flags); + mEventDispatcher.dispatch("GeckoView:Reload", msg); + } + + /** Stop loading. */ + @AnyThread + public void stop() { + mEventDispatcher.dispatch("GeckoView:Stop", null); + } + + /** + * Go back in history and assumes the call was based on a user interaction. + * + * @see #goBack(boolean) + */ + @AnyThread + public void goBack() { + goBack(true); + } + + /** + * Go back in history. + * + * @param userInteraction Whether the action was invoked by a user interaction. + */ + @AnyThread + public void goBack(final boolean userInteraction) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("userInteraction", userInteraction); + mEventDispatcher.dispatch("GeckoView:GoBack", msg); + } + + /** + * Go forward in history and assumes the call was based on a user interaction. + * + * @see #goForward(boolean) + */ + @AnyThread + public void goForward() { + goForward(true); + } + + /** + * Go forward in history. + * + * @param userInteraction Whether the action was invoked by a user interaction. + */ + @AnyThread + public void goForward(final boolean userInteraction) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("userInteraction", userInteraction); + mEventDispatcher.dispatch("GeckoView:GoForward", msg); + } + + /** + * Navigate to an index in browser history; the index of the currently viewed page can be + * retrieved from an up-to-date HistoryList by calling {@link + * HistoryDelegate.HistoryList#getCurrentIndex()}. + * + * @param index The index of the location in browser history you want to navigate to. + */ + @AnyThread + public void gotoHistoryIndex(final int index) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putInt("index", index); + mEventDispatcher.dispatch("GeckoView:GotoHistoryIndex", msg); + } + + /** + * Returns a WebExtensionController for this GeckoSession. Delegates attached to this controller + * will receive events specific to this session. + * + * @return an instance of {@link WebExtension.SessionController}. + */ + @UiThread + public @NonNull WebExtension.SessionController getWebExtensionController() { + return mWebExtensionController; + } + + /** + * Purge history for the session. The session history is used for back and forward history. + * Purging the session history means {@link NavigationDelegate#onCanGoBack(GeckoSession, boolean)} + * and {@link NavigationDelegate#onCanGoForward(GeckoSession, boolean)} will be false. + */ + @AnyThread + public void purgeHistory() { + mEventDispatcher.dispatch("GeckoView:PurgeHistory", null); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FINDER_FIND_BACKWARDS, + FINDER_FIND_LINKS_ONLY, + FINDER_FIND_MATCH_CASE, + FINDER_FIND_WHOLE_WORD + }) + public @interface FinderFindFlags {} + + /** Go backwards when finding the next match. */ + public static final int FINDER_FIND_BACKWARDS = 1; + /** Perform case-sensitive match; default is to perform a case-insensitive match. */ + public static final int FINDER_FIND_MATCH_CASE = 1 << 1; + /** Must match entire words; default is to allow matching partial words. */ + public static final int FINDER_FIND_WHOLE_WORD = 1 << 2; + /** Limit matches to links on the page. */ + public static final int FINDER_FIND_LINKS_ONLY = 1 << 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + FINDER_DISPLAY_HIGHLIGHT_ALL, + FINDER_DISPLAY_DIM_PAGE, + FINDER_DISPLAY_DRAW_LINK_OUTLINE + }) + public @interface FinderDisplayFlags {} + + /** Highlight all find-in-page matches. */ + public static final int FINDER_DISPLAY_HIGHLIGHT_ALL = 1; + /** Dim the rest of the page when showing a find-in-page match. */ + public static final int FINDER_DISPLAY_DIM_PAGE = 1 << 1; + /** Draw outlines around matching links. */ + public static final int FINDER_DISPLAY_DRAW_LINK_OUTLINE = 1 << 2; + + /** Represent the result of a find-in-page operation. */ + @AnyThread + public static class FinderResult { + /** Whether a match was found. */ + public final boolean found; + /** Whether the search wrapped around the top or bottom of the page. */ + public final boolean wrapped; + /** Ordinal number of the current match starting from 1, or 0 if no match. */ + public final int current; + /** Total number of matches found so far, or -1 if unknown. */ + public final int total; + /** Search string. */ + @NonNull public final String searchString; + /** + * Flags used for the search; either 0 or a combination of {@link #FINDER_FIND_BACKWARDS + * FINDER_FIND_*} flags. + */ + @FinderFindFlags public final int flags; + /** URI of the link, if the current match is a link, or null otherwise. */ + @Nullable public final String linkUri; + /** Bounds of the current match in client coordinates, or null if unknown. */ + @Nullable public final RectF clientRect; + + /* package */ FinderResult(@NonNull final GeckoBundle bundle) { + found = bundle.getBoolean("found"); + wrapped = bundle.getBoolean("wrapped"); + current = bundle.getInt("current", 0); + total = bundle.getInt("total", -1); + searchString = bundle.getString("searchString"); + flags = SessionFinder.getFlagsFromBundle(bundle.getBundle("flags")); + linkUri = bundle.getString("linkURL"); + clientRect = bundle.getRectF("clientRect"); + } + + /** Empty constructor for tests */ + protected FinderResult() { + found = false; + wrapped = false; + current = 0; + total = 0; + flags = 0; + searchString = ""; + linkUri = ""; + clientRect = null; + } + } + + /** + * Get the SessionFinder instance for this session, to perform find-in-page operations. + * + * @return SessionFinder instance. + */ + @AnyThread + public @NonNull SessionFinder getFinder() { + if (mFinder == null) { + mFinder = new SessionFinder(getEventDispatcher()); + } + return mFinder; + } + + /** + * Set this GeckoSession as active or inactive, which represents if the session is currently + * visible or not. Setting a GeckoSession to inactive will significantly reduce its memory + * footprint, but should only be done if the GeckoSession is not currently visible. Note that a + * session can be active (i.e. visible) but not focused. When a session is set inactive, it will + * flush the session state and trigger a `ProgressDelegate.onSessionStateChange` callback. + * + * @param active A boolean determining whether the GeckoSession is active. + * @see #setFocused + */ + @AnyThread + public void setActive(final boolean active) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("active", active); + mEventDispatcher.dispatch("GeckoView:SetActive", msg); + + if (!active) { + mEventDispatcher.dispatch("GeckoView:FlushSessionState", null); + ThreadUtils.postToUiThreadDelayed(mNotifyMemoryPressure, NOTIFY_MEMORY_PRESSURE_DELAY_MS); + } else { + // Delete any pending memory pressure events since we're active again. + ThreadUtils.removeUiThreadCallbacks(mNotifyMemoryPressure); + } + + ThreadUtils.runOnUiThread(() -> getAutofillSupport().onActiveChanged(active)); + } + + /** + * Move focus to this session or away from this session. Only one session has focus at a given + * time. Note that a session can be unfocused but still active (i.e. visible). + * + * @param focused True if the session should gain focus or false if the session should lose focus. + * @see #setActive + */ + @AnyThread + public void setFocused(final boolean focused) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putBoolean("focused", focused); + mEventDispatcher.dispatch("GeckoView:SetFocused", msg); + } + + /** + * Notify GeckoView of the priority for this GeckoSession. + * + * <p>Set this GeckoSession to high priority (PRIORITY_HIGH) whenever the app wants to signal to + * GeckoView that this GeckoSession is important to the app. GeckoView will keep the session state + * as long as possible. Set this to default priority (PRIORITY_DEFAULT) in any other case. + * + * @param priorityHint Priority of the geckosession, either high priority or default. + */ + @AnyThread + public void setPriorityHint(final @Priority int priorityHint) { + final GeckoBundle msg = new GeckoBundle(1); + msg.putInt("priorityHint", priorityHint); + mEventDispatcher.dispatch("GeckoView:SetPriorityHint", msg); + } + + /** Class representing a saved session state. */ + @AnyThread + public static class SessionState extends AbstractSequentialList<HistoryDelegate.HistoryItem> + implements HistoryDelegate.HistoryList, Parcelable { + private GeckoBundle mState; + + private class SessionStateItem implements HistoryDelegate.HistoryItem { + private final GeckoBundle mItem; + + private SessionStateItem(final @NonNull GeckoBundle item) { + mItem = item; + } + + @Override /* HistoryItem */ + public String getUri() { + return mItem.getString("url"); + } + + @Override /* HistoryItem */ + public String getTitle() { + return mItem.getString("title"); + } + } + + private class SessionStateIterator implements ListIterator<HistoryDelegate.HistoryItem> { + private final SessionState mState; + private int mIndex; + + private SessionStateIterator(final @NonNull SessionState state) { + this(state, 0); + } + + private SessionStateIterator(final @NonNull SessionState state, final int index) { + mIndex = index; + mState = state; + } + + @Override /* ListIterator */ + public void add(final HistoryDelegate.HistoryItem item) { + throw new UnsupportedOperationException(); + } + + @Override /* ListIterator */ + public boolean hasNext() { + final GeckoBundle[] entries = mState.getHistoryEntries(); + + if (entries == null) { + Log.w(LOGTAG, "No history entries found."); + return false; + } + + if (mIndex >= mState.getHistoryEntries().length) { + return false; + } + return true; + } + + @Override /* ListIterator */ + public boolean hasPrevious() { + if (mIndex <= 0) { + return false; + } + return true; + } + + @Override /* ListIterator */ + public HistoryDelegate.HistoryItem next() { + if (hasNext()) { + mIndex++; + return new SessionStateItem(mState.getHistoryEntries()[mIndex - 1]); + } else { + throw new NoSuchElementException(); + } + } + + @Override /* ListIterator */ + public int nextIndex() { + return mIndex; + } + + @Override /* ListIterator */ + public HistoryDelegate.HistoryItem previous() { + if (hasPrevious()) { + mIndex--; + return new SessionStateItem(mState.getHistoryEntries()[mIndex]); + } else { + throw new NoSuchElementException(); + } + } + + @Override /* ListIterator */ + public int previousIndex() { + return mIndex - 1; + } + + @Override /* ListIterator */ + public void remove() { + throw new UnsupportedOperationException(); + } + + @Override /* ListIterator */ + public void set(final @NonNull HistoryDelegate.HistoryItem item) { + throw new UnsupportedOperationException(); + } + } + + private SessionState() { + mState = new GeckoBundle(3); + } + + private SessionState(final @NonNull GeckoBundle state) { + mState = new GeckoBundle(state); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public SessionState(final @NonNull SessionState state) { + mState = new GeckoBundle(state.mState); + } + + /* package */ void updateSessionState(final @NonNull GeckoBundle updateData) { + if (updateData == null) { + Log.w(LOGTAG, "Session state update has no data field."); + return; + } + + final GeckoBundle history = updateData.getBundle("historychange"); + final GeckoBundle scroll = updateData.getBundle("scroll"); + final GeckoBundle formdata = updateData.getBundle("formdata"); + + if (history != null) { + mState.putBundle("history", history); + } + + if (scroll != null) { + mState.putBundle("scrolldata", scroll); + } + + if (formdata != null) { + mState.putBundle("formdata", formdata); + } + + return; + } + + @Override + public int hashCode() { + return mState.hashCode(); + } + + @Override + public boolean equals(final Object other) { + if (other == null || !(other instanceof SessionState)) { + return false; + } + + final SessionState otherState = (SessionState) other; + + return this.mState.equals(otherState.mState); + } + + /** + * Creates a new SessionState instance from a value previously returned by {@link #toString()}. + * + * @param value The serialized SessionState in String form. + * @return A new SessionState instance if input is valid; otherwise null. + */ + public static @Nullable SessionState fromString(final @Nullable String value) { + final GeckoBundle bundleState; + try { + bundleState = GeckoBundle.fromJSONObject(new JSONObject(value)); + } catch (final Exception e) { + Log.e(LOGTAG, "String does not represent valid session state."); + return null; + } + + if (bundleState == null) { + return null; + } + + return new SessionState(bundleState); + } + + @Override + public @Nullable String toString() { + if (mState == null) { + Log.w(LOGTAG, "Can't convert SessionState with null state to string"); + return null; + } + + String res; + try { + res = mState.toJSONObject().toString(); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert session state to string."); + res = null; + } + + return res; + } + + @Override // Parcelable + public int describeContents() { + return 0; + } + + @Override // Parcelable + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeString(toString()); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + if (source.readString() == null) { + Log.w(LOGTAG, "Can't reproduce session state from Parcel"); + } + + try { + mState = GeckoBundle.fromJSONObject(new JSONObject(source.readString())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert string to session state."); + mState = null; + } + } + + public static final Parcelable.Creator<SessionState> CREATOR = + new Parcelable.Creator<SessionState>() { + @Override + public SessionState createFromParcel(final Parcel source) { + if (source.readString() == null) { + Log.w(LOGTAG, "Can't create session state from Parcel"); + } + + GeckoBundle res; + try { + res = GeckoBundle.fromJSONObject(new JSONObject(source.readString())); + } catch (final JSONException e) { + Log.e(LOGTAG, "Could not convert parcel to session state."); + res = null; + } + + return new SessionState(res); + } + + @Override + public SessionState[] newArray(final int size) { + return new SessionState[size]; + } + }; + + @Override /* AbstractSequentialList */ + public @NonNull HistoryDelegate.HistoryItem get(final int index) { + final GeckoBundle[] entries = getHistoryEntries(); + + if (entries == null || index < 0 || index >= entries.length) { + throw new NoSuchElementException(); + } + + return new SessionStateItem(entries[index]); + } + + @Override /* AbstractSequentialList */ + public @NonNull Iterator<HistoryDelegate.HistoryItem> iterator() { + return listIterator(0); + } + + @Override /* AbstractSequentialList */ + public @NonNull ListIterator<HistoryDelegate.HistoryItem> listIterator(final int index) { + return new SessionStateIterator(this, index); + } + + @Override /* AbstractSequentialList */ + public int size() { + final GeckoBundle[] entries = getHistoryEntries(); + + if (entries == null) { + Log.w(LOGTAG, "No history entries found."); + return 0; + } + + return entries.length; + } + + @Override /* HistoryList */ + public int getCurrentIndex() { + final GeckoBundle history = getHistory(); + + if (history == null) { + throw new IllegalStateException("No history state exists."); + } + + return history.getInt("index") + history.getInt("fromIdx"); + } + + // Some helpers for common code. + private GeckoBundle getHistory() { + if (mState == null) { + return null; + } + + return mState.getBundle("history"); + } + + private GeckoBundle[] getHistoryEntries() { + final GeckoBundle history = getHistory(); + + if (history == null) { + return null; + } + + return history.getBundleArray("entries"); + } + } + + private SessionState mStateCache = new SessionState(); + + /** + * Restore a saved state to this GeckoSession; only data that is saved (history, scroll position, + * zoom, and form data) will be restored. These will overwrite the corresponding state of this + * GeckoSession. + * + * @param state A saved session state; this should originate from onSessionStateChange(). + */ + @AnyThread + public void restoreState(final @NonNull SessionState state) { + mEventDispatcher.dispatch("GeckoView:RestoreState", state.mState); + } + + /** + * Get whether this GeckoSession has form data. + * + * @return a {@link GeckoResult} result of if there is existing form data. + */ + @AnyThread + public @NonNull GeckoResult<Boolean> containsFormData() { + return mEventDispatcher.queryBoolean("GeckoView:ContainsFormData"); + } + + // This is the GeckoDisplay acquired via acquireDisplay(), if any. + private GeckoDisplay mDisplay; + + /* package */ interface Owner { + void onRelease(); + } + + private static final WeakReference<Owner> NO_OWNER = new WeakReference<>(null); + private WeakReference<Owner> mOwner = NO_OWNER; + + @UiThread + /* package */ void releaseOwner() { + ThreadUtils.assertOnUiThread(); + mOwner = NO_OWNER; + } + + @UiThread + /* package */ void setOwner(final Owner owner) { + ThreadUtils.assertOnUiThread(); + final Owner oldOwner = mOwner.get(); + if (oldOwner != null && owner != oldOwner) { + oldOwner.onRelease(); + } + mOwner = new WeakReference<>(owner); + } + + /* package */ GeckoDisplay getDisplay() { + return mDisplay; + } + + /** + * Acquire the GeckoDisplay instance for providing the session with a drawing Surface. Be sure to + * call {@link GeckoDisplay#surfaceChanged(SurfaceInfo)} on the acquired display if there is + * already a valid Surface. + * + * @return GeckoDisplay instance. + * @see #releaseDisplay(GeckoDisplay) + */ + @UiThread + public @NonNull GeckoDisplay acquireDisplay() { + ThreadUtils.assertOnUiThread(); + + if (mDisplay != null) { + throw new IllegalStateException("Display already acquired"); + } + + mDisplay = new GeckoDisplay(this); + return mDisplay; + } + + /** + * Release an acquired GeckoDisplay instance. Be sure to call {@link + * GeckoDisplay#surfaceDestroyed()} before releasing the display if it still has a valid Surface. + * + * @param display Acquired GeckoDisplay instance. + * @see #acquireDisplay() + */ + @UiThread + public void releaseDisplay(final @NonNull GeckoDisplay display) { + ThreadUtils.assertOnUiThread(); + + if (display != mDisplay) { + throw new IllegalArgumentException("Display not attached"); + } + + mDisplay = null; + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull GeckoSessionSettings getSettings() { + return mSettings; + } + + /** Exits fullscreen mode */ + @AnyThread + public void exitFullScreen() { + mEventDispatcher.dispatch("GeckoViewContent:ExitFullScreen", null); + } + + /** + * Set the content callback handler. This will replace the current handler. + * + * @param delegate An implementation of ContentDelegate. + */ + @UiThread + public void setContentDelegate(final @Nullable ContentDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mContentHandler.setDelegate(delegate, this); + mProcessHangHandler.setDelegate(delegate, this); + } + + /** + * Get the content callback handler. + * + * @return The current content callback handler. + */ + @UiThread + public @Nullable ContentDelegate getContentDelegate() { + ThreadUtils.assertOnUiThread(); + return mContentHandler.getDelegate(); + } + + /** + * Set the progress callback handler. This will replace the current handler. + * + * @param delegate An implementation of ProgressDelegate. + */ + @UiThread + public void setProgressDelegate(final @Nullable ProgressDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mProgressHandler.setDelegate(delegate, this); + } + + /** + * Get the progress callback handler. + * + * @return The current progress callback handler. + */ + @UiThread + public @Nullable ProgressDelegate getProgressDelegate() { + ThreadUtils.assertOnUiThread(); + return mProgressHandler.getDelegate(); + } + + /** + * Set the navigation callback handler. This will replace the current handler. + * + * @param delegate An implementation of NavigationDelegate. + */ + @UiThread + public void setNavigationDelegate(final @Nullable NavigationDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mNavigationHandler.setDelegate(delegate, this); + } + + /** + * Get the navigation callback handler. + * + * @return The current navigation callback handler. + */ + @UiThread + public @Nullable NavigationDelegate getNavigationDelegate() { + ThreadUtils.assertOnUiThread(); + return mNavigationHandler.getDelegate(); + } + + /** + * Set the content scroll callback handler. This will replace the current handler. + * + * @param delegate An implementation of ScrollDelegate. + */ + @UiThread + public void setScrollDelegate(final @Nullable ScrollDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mScrollHandler.setDelegate(delegate, this); + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable ScrollDelegate getScrollDelegate() { + ThreadUtils.assertOnUiThread(); + return mScrollHandler.getDelegate(); + } + + /** + * Set the history tracking delegate for this session, replacing the current delegate if one is + * set. + * + * @param delegate The history tracking delegate, or {@code null} to unset. + */ + @AnyThread + public void setHistoryDelegate(final @Nullable HistoryDelegate delegate) { + mHistoryHandler.setDelegate(delegate, this); + } + + /** + * @return The history tracking delegate for this session. + */ + @AnyThread + public @Nullable HistoryDelegate getHistoryDelegate() { + return mHistoryHandler.getDelegate(); + } + + /** + * Set the content blocking callback handler. This will replace the current handler. + * + * @param delegate An implementation of {@link ContentBlocking.Delegate}. + */ + @AnyThread + public void setContentBlockingDelegate(final @Nullable ContentBlocking.Delegate delegate) { + mContentBlockingHandler.setDelegate(delegate, this); + } + + /** + * Get the content blocking callback handler. + * + * @return The current content blocking callback handler. + */ + @AnyThread + public @Nullable ContentBlocking.Delegate getContentBlockingDelegate() { + return mContentBlockingHandler.getDelegate(); + } + + /** + * Set the current prompt delegate for this GeckoSession. + * + * @param delegate PromptDelegate instance or null to use the built-in delegate. + */ + @AnyThread + public void setPromptDelegate(final @Nullable PromptDelegate delegate) { + mPromptDelegate = delegate; + } + + /** + * Get the current prompt delegate for this GeckoSession. + * + * @return PromptDelegate instance or null if using built-in delegate. + */ + @AnyThread + public @Nullable PromptDelegate getPromptDelegate() { + return mPromptDelegate; + } + + /** + * Set the current selection action delegate for this GeckoSession. + * + * @param delegate SelectionActionDelegate instance or null to unset. + */ + @UiThread + public void setSelectionActionDelegate(final @Nullable SelectionActionDelegate delegate) { + ThreadUtils.assertOnUiThread(); + + if (getSelectionActionDelegate() != null) { + // When the delegate is changed or cleared, make sure onHideAction is called + // one last time to hide any existing selection action UI. Gecko doesn't keep + // track of the old delegate, so we can't rely on Gecko to do that for us. + getSelectionActionDelegate() + .onHideAction(this, GeckoSession.SelectionActionDelegate.HIDE_REASON_NO_SELECTION); + } + mSelectionActionDelegate.setDelegate(delegate, this); + } + + /** + * Set the media callback handler. This will replace the current handler. + * + * @param delegate An implementation of MediaDelegate. + */ + @AnyThread + public void setMediaDelegate(final @Nullable MediaDelegate delegate) { + mMediaHandler.setDelegate(delegate, this); + } + + /** + * Get the Media callback handler. + * + * @return The current Media callback handler. + */ + @AnyThread + public @Nullable MediaDelegate getMediaDelegate() { + return mMediaHandler.getDelegate(); + } + + /** + * Set the media session delegate. This will replace the current handler. + * + * @param delegate An implementation of {@link MediaSession.Delegate}. + */ + @AnyThread + public void setMediaSessionDelegate(final @Nullable MediaSession.Delegate delegate) { + mMediaSessionHandler.setDelegate(delegate, this); + } + + /** + * Get the media session delegate. + * + * @return The current media session delegate. + */ + @AnyThread + public @Nullable MediaSession.Delegate getMediaSessionDelegate() { + return mMediaSessionHandler.getDelegate(); + } + + /** + * Get the current selection action delegate for this GeckoSession. + * + * @return SelectionActionDelegate instance or null if not set. + */ + @AnyThread + public @Nullable SelectionActionDelegate getSelectionActionDelegate() { + return mSelectionActionDelegate.getDelegate(); + } + + @UiThread + protected void setShouldPinOnScreen(final boolean pinned) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mShouldPinOnScreen = pinned; + } + + /* package */ boolean shouldPinOnScreen() { + ThreadUtils.assertOnUiThread(); + return mShouldPinOnScreen; + } + + @AnyThread + /* package */ @NonNull + EventDispatcher getEventDispatcher() { + return mEventDispatcher; + } + + public interface ProgressDelegate { + /** Class representing security information for a site. */ + public class SecurityInformation { + @Retention(RetentionPolicy.SOURCE) + @IntDef({SECURITY_MODE_UNKNOWN, SECURITY_MODE_IDENTIFIED, SECURITY_MODE_VERIFIED}) + public @interface SecurityMode {} + + public static final int SECURITY_MODE_UNKNOWN = 0; + public static final int SECURITY_MODE_IDENTIFIED = 1; + public static final int SECURITY_MODE_VERIFIED = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({CONTENT_UNKNOWN, CONTENT_BLOCKED, CONTENT_LOADED}) + public @interface ContentType {} + + public static final int CONTENT_UNKNOWN = 0; + public static final int CONTENT_BLOCKED = 1; + public static final int CONTENT_LOADED = 2; + /** Indicates whether or not the site is secure. */ + public final boolean isSecure; + /** Indicates whether or not the site is a security exception. */ + public final boolean isException; + /** Contains the origin of the certificate. */ + public final @Nullable String origin; + /** Contains the host associated with the certificate. */ + public final @NonNull String host; + + /** The server certificate in use, if any. */ + public final @Nullable X509Certificate certificate; + + /** + * Indicates the security level of the site; possible values are SECURITY_MODE_UNKNOWN, + * SECURITY_MODE_IDENTIFIED, and SECURITY_MODE_VERIFIED. SECURITY_MODE_IDENTIFIED indicates + * domain validation only, while SECURITY_MODE_VERIFIED indicates extended validation. + */ + public final @SecurityMode int securityMode; + /** + * Indicates the presence of passive mixed content; possible values are CONTENT_UNKNOWN, + * CONTENT_BLOCKED, and CONTENT_LOADED. + */ + public final @ContentType int mixedModePassive; + /** + * Indicates the presence of active mixed content; possible values are CONTENT_UNKNOWN, + * CONTENT_BLOCKED, and CONTENT_LOADED. + */ + public final @ContentType int mixedModeActive; + + /* package */ SecurityInformation(final GeckoBundle identityData) { + final GeckoBundle mode = identityData.getBundle("mode"); + + mixedModePassive = mode.getInt("mixed_display"); + mixedModeActive = mode.getInt("mixed_active"); + + securityMode = mode.getInt("identity"); + + isSecure = identityData.getBoolean("secure"); + isException = identityData.getBoolean("securityException"); + origin = identityData.getString("origin"); + host = identityData.getString("host"); + + X509Certificate decodedCert = null; + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + final String certString = identityData.getString("certificate"); + if (certString != null) { + final byte[] certBytes = Base64.decode(certString, Base64.NO_WRAP); + decodedCert = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certBytes)); + } + } catch (final CertificateException e) { + Log.e(LOGTAG, "Failed to decode certificate", e); + } + + certificate = decodedCert; + } + + /** Empty constructor for tests */ + protected SecurityInformation() { + mixedModePassive = CONTENT_UNKNOWN; + mixedModeActive = CONTENT_UNKNOWN; + securityMode = SECURITY_MODE_UNKNOWN; + isSecure = false; + isException = false; + origin = ""; + host = ""; + certificate = null; + } + } + + /** + * A View has started loading content from the network. + * + * @param session GeckoSession that initiated the callback. + * @param url The resource being loaded. + */ + @UiThread + default void onPageStart(@NonNull final GeckoSession session, @NonNull final String url) {} + + /** + * A View has finished loading content from the network. + * + * @param session GeckoSession that initiated the callback. + * @param success Whether the page loaded successfully or an error occurred. + */ + @UiThread + default void onPageStop(@NonNull final GeckoSession session, final boolean success) {} + + /** + * Page loading has progressed. + * + * @param session GeckoSession that initiated the callback. + * @param progress Current page load progress value [0, 100]. + */ + @UiThread + default void onProgressChange(@NonNull final GeckoSession session, final int progress) {} + + /** + * The security status has been updated. + * + * @param session GeckoSession that initiated the callback. + * @param securityInfo The new security information. + */ + @UiThread + default void onSecurityChange( + @NonNull final GeckoSession session, @NonNull final SecurityInformation securityInfo) {} + + /** + * The browser session state has changed. This can happen in response to navigation, scrolling, + * or form data changes; the session state passed includes the most up to date information on + * all of these. + * + * @param session GeckoSession that initiated the callback. + * @param sessionState SessionState representing the latest browser state. + */ + @UiThread + default void onSessionStateChange( + @NonNull final GeckoSession session, @NonNull final SessionState sessionState) {} + } + + /** WebResponseInfo contains information about a single web response. */ + @AnyThread + public static class WebResponseInfo { + /** The URI of the response. Cannot be null. */ + @NonNull public final String uri; + + /** The content type (mime type) of the response. May be null. */ + @Nullable public final String contentType; + + /** The content length of the response. May be 0 if unknokwn. */ + @Nullable public final long contentLength; + + /** The filename obtained from the content disposition, if any. May be null. */ + @Nullable public final String filename; + + /* package */ WebResponseInfo(final GeckoBundle message) { + uri = message.getString("uri"); + if (uri == null) { + throw new IllegalArgumentException("URI cannot be null"); + } + + contentType = message.getString("contentType"); + contentLength = message.getLong("contentLength"); + filename = message.getString("filename"); + } + + /** Empty constructor for tests. */ + protected WebResponseInfo() { + uri = ""; + contentType = ""; + contentLength = 0; + filename = ""; + } + } + + public interface ContentDelegate { + /** + * A page title was discovered in the content or updated after the content loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param title The title sent from the content. + */ + @UiThread + default void onTitleChange(@NonNull final GeckoSession session, @Nullable final String title) {} + + /** + * A preview image was discovered in the content after the content loaded. + * + * @param session The GeckoSession that initiated the callback. + * @param previewImageUrl The preview image URL sent from the content. + */ + @UiThread + default void onPreviewImage( + @NonNull final GeckoSession session, @NonNull final String previewImageUrl) {} + + /** + * A page has requested focus. Note that window.focus() in content will not result in this being + * called. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onFocusRequest(@NonNull final GeckoSession session) {} + + /** + * A page has requested to close + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onCloseRequest(@NonNull final GeckoSession session) {} + + /** + * A page has entered or exited full screen mode. Typically, the implementation would set the + * Activity containing the GeckoSession to full screen when the page is in full screen mode. + * + * @param session The GeckoSession that initiated the callback. + * @param fullScreen True if the page is in full screen mode. + */ + @UiThread + default void onFullScreen(@NonNull final GeckoSession session, final boolean fullScreen) {} + + /** + * A viewport-fit was discovered in the content or updated after the content. + * + * @param session The GeckoSession that initiated the callback. + * @param viewportFit The value of viewport-fit of meta element in content. + * @see <a href="https://drafts.csswg.org/css-round-display/#viewport-fit-descriptor">4.1. The + * viewport-fit descriptor</a> + */ + @UiThread + default void onMetaViewportFitChange( + @NonNull final GeckoSession session, @NonNull final String viewportFit) {} + + /** Element details for onContextMenu callbacks. */ + public static class ContextElement { + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_NONE, TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO}) + public @interface Type {} + + public static final int TYPE_NONE = 0; + public static final int TYPE_IMAGE = 1; + public static final int TYPE_VIDEO = 2; + public static final int TYPE_AUDIO = 3; + + /** The base URI of the element's document. */ + public final @Nullable String baseUri; + + /** The absolute link URI (href) of the element. */ + public final @Nullable String linkUri; + + /** The title text of the element. */ + public final @Nullable String title; + + /** The alternative text (alt) for the element. */ + public final @Nullable String altText; + + /** The type of the element. One of the {@link ContextElement#TYPE_NONE} flags. */ + public final @Type int type; + + /** The source URI (src) of the element. Set for (nested) media elements. */ + public final @Nullable String srcUri; + + // TODO: Bug 1595822 make public + final List<WebExtension.Menu> extensionMenus; + + protected ContextElement( + final @Nullable String baseUri, + final @Nullable String linkUri, + final @Nullable String title, + final @Nullable String altText, + final @NonNull String typeStr, + final @Nullable String srcUri) { + this.baseUri = baseUri; + this.linkUri = linkUri; + this.title = title; + this.altText = altText; + this.type = getType(typeStr); + this.srcUri = srcUri; + this.extensionMenus = null; + } + + private static int getType(final String name) { + if ("HTMLImageElement".equals(name)) { + return TYPE_IMAGE; + } else if ("HTMLVideoElement".equals(name)) { + return TYPE_VIDEO; + } else if ("HTMLAudioElement".equals(name)) { + return TYPE_AUDIO; + } + return TYPE_NONE; + } + } + + /** + * A user has initiated the context menu via long-press. This event is fired on links, (nested) + * images and (nested) media elements. + * + * @param session The GeckoSession that initiated the callback. + * @param screenX The screen coordinates of the press. + * @param screenY The screen coordinates of the press. + * @param element The details for the pressed element. + */ + @UiThread + default void onContextMenu( + @NonNull final GeckoSession session, + final int screenX, + final int screenY, + @NonNull final ContextElement element) {} + + /** + * This is fired when there is a response that cannot be handled by Gecko (e.g., a download). + * + * @param session the GeckoSession that received the external response. + * @param response the external WebResponse. + */ + @UiThread + default void onExternalResponse( + @NonNull final GeckoSession session, @NonNull final WebResponse response) {} + + /** + * The content process hosting this GeckoSession has crashed. The GeckoSession is now closed and + * unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state is + * preserved. Most applications will want to call {@link #load} or {@link + * #restoreState(SessionState)} at this point. + * + * @param session The GeckoSession for which the content process has crashed. + */ + @UiThread + default void onCrash(@NonNull final GeckoSession session) {} + + /** + * The content process hosting this GeckoSession has been killed. The GeckoSession is now closed + * and unusable. You may call {@link #open(GeckoRuntime)} to recover the session, but no state + * is preserved. Most applications will want to call {@link #load} or {@link + * #restoreState(SessionState)} at this point. + * + * @param session The GeckoSession for which the content process has been killed. + */ + @UiThread + default void onKill(@NonNull final GeckoSession session) {} + + /** + * Notification that the first content composition has occurred. This callback is invoked for + * the first content composite after either a start or a restart of the compositor. + * + * @param session The GeckoSession that had a first paint event. + */ + @UiThread + default void onFirstComposite(@NonNull final GeckoSession session) {} + + /** + * Notification that the first content paint has occurred. This callback is invoked for the + * first content paint after a page has been loaded, or after a {@link + * #onPaintStatusReset(GeckoSession)} event. The function {@link + * #onFirstComposite(GeckoSession)} will be called once the compositor has started rendering. + * However, it is possible for the compositor to start rendering before there is any content to + * render. onFirstContentfulPaint() is called once some content has been rendered. It may be + * nothing more than the page background color. It is not an indication that the whole page has + * been rendered. + * + * @param session The GeckoSession that had a first paint event. + */ + @UiThread + default void onFirstContentfulPaint(@NonNull final GeckoSession session) {} + + /** + * Notification that the paint status has been reset. + * + * <p>This callback is invoked whenever the painted content is no longer being displayed. This + * can occur in response to the session being paused. After this has fired the compositor may + * continue rendering, but may not render the page content. This callback can therefore be used + * in conjunction with {@link #onFirstContentfulPaint(GeckoSession)} to determine when there is + * valid content being rendered. + * + * @param session The GeckoSession that had the paint status reset event. + */ + @UiThread + default void onPaintStatusReset(@NonNull final GeckoSession session) {} + + /** + * A page has requested to change pointer icon. + * + * <p>If the application wants to control pointer icon, it should override this, then handle it. + * + * @param session The GeckoSession that initiated the callback. + * @param icon The pointer icon sent from the content. + */ + @TargetApi(Build.VERSION_CODES.N) + @UiThread + default void onPointerIconChange( + @NonNull final GeckoSession session, @NonNull final PointerIcon icon) { + final View view = session.getTextInput().getView(); + if (view != null) { + view.setPointerIcon(icon); + } + } + + /** + * This is fired when the loaded document has a valid Web App Manifest present. + * + * <p>The various colors (theme_color, background_color, etc.) present in the manifest have been + * transformed into #AARRGGBB format. + * + * @param session The GeckoSession that contains the Web App Manifest + * @param manifest A parsed and validated {@link JSONObject} containing the manifest contents. + * @see <a href="https://www.w3.org/TR/appmanifest/">Web App Manifest specification</a> + */ + @UiThread + default void onWebAppManifest( + @NonNull final GeckoSession session, @NonNull final JSONObject manifest) {} + + /** + * A script has exceeded its execution timeout value + * + * @param geckoSession GeckoSession that initiated the callback. + * @param scriptFileName Filename of the slow script + * @return A {@link GeckoResult} with a SlowScriptResponse value which indicates whether to + * allow the Slow Script to continue processing. Stop will halt the slow script. Continue + * will pause notifications for a period of time before resuming. + */ + @UiThread + default @Nullable GeckoResult<SlowScriptResponse> onSlowScript( + @NonNull final GeckoSession geckoSession, @NonNull final String scriptFileName) { + return null; + } + + /** + * The app should display its dynamic toolbar, fully expanded to the height that was previously + * specified via {@link GeckoView#setDynamicToolbarMaxHeight}. + * + * @param geckoSession GeckoSession that initiated the callback. + */ + @UiThread + default void onShowDynamicToolbar(@NonNull final GeckoSession geckoSession) {} + + /** + * This method is called when a cookie banner was detected. + * + * <p>Note: this method is called only if the cookie banner setting is such that allows to + * handle the banner. For example, if cookiebanners.service.mode=1 (Reject only) but a cookie + * banner can only be accepted on the website - the detection in that case won't be reported. + * The exception is MODE_DETECT_ONLY mode, when only the detection event is emitted. + * + * @param session GeckoSession that initiated the callback. + */ + @AnyThread + default void onCookieBannerDetected(@NonNull final GeckoSession session) {} + + /** + * This method is called when a cookie banner was handled. + * + * @param session GeckoSession that initiated the callback. + */ + @AnyThread + default void onCookieBannerHandled(@NonNull final GeckoSession session) {} + } + + public interface SelectionActionDelegate { + /** The selection is collapsed at a single position. */ + final int FLAG_IS_COLLAPSED = 1; + /** + * The selection is inside editable content such as an input element or contentEditable node. + */ + final int FLAG_IS_EDITABLE = 2; + /** The selection is inside a password field. */ + final int FLAG_IS_PASSWORD = 4; + + /** Hide selection actions and cause {@link #onHideAction} to be called. */ + final String ACTION_HIDE = "org.mozilla.geckoview.HIDE"; + /** Copy onto the clipboard then delete the selected content. Selection must be editable. */ + final String ACTION_CUT = "org.mozilla.geckoview.CUT"; + /** Copy the selected content onto the clipboard. */ + final String ACTION_COPY = "org.mozilla.geckoview.COPY"; + /** Delete the selected content. Selection must be editable. */ + final String ACTION_DELETE = "org.mozilla.geckoview.DELETE"; + /** Replace the selected content with the clipboard content. Selection must be editable. */ + final String ACTION_PASTE = "org.mozilla.geckoview.PASTE"; + /** + * Replace the selected content with the clipboard content as plain text. Selection must be + * editable. + */ + final String ACTION_PASTE_AS_PLAIN_TEXT = "org.mozilla.geckoview.PASTE_AS_PLAIN_TEXT"; + /** Select the entire content of the document or editor. */ + final String ACTION_SELECT_ALL = "org.mozilla.geckoview.SELECT_ALL"; + /** Clear the current selection. Selection must not be editable. */ + final String ACTION_UNSELECT = "org.mozilla.geckoview.UNSELECT"; + /** Collapse the current selection to its start position. Selection must be editable. */ + final String ACTION_COLLAPSE_TO_START = "org.mozilla.geckoview.COLLAPSE_TO_START"; + /** Collapse the current selection to its end position. Selection must be editable. */ + final String ACTION_COLLAPSE_TO_END = "org.mozilla.geckoview.COLLAPSE_TO_END"; + + /** Represents attributes of a selection. */ + class Selection { + /** + * Flags describing the current selection, as a bitwise combination of the {@link + * #FLAG_IS_COLLAPSED FLAG_*} constants. + */ + public final @SelectionActionDelegateFlag int flags; + + /** + * Text content of the current selection. An empty string indicates the selection is collapsed + * or the selection cannot be represented as plain text. + */ + public final @NonNull String text; + + /** + * The bounds of the current selection in client coordinates. Use {@link + * GeckoSession#getClientToScreenMatrix} to perform transformation to screen coordinates. + * + * @deprecated Use {@link #screenRect}. + */ + @Deprecated + @DeprecationSchedule(id = "selection-fission", version = 112) + public final @Nullable RectF clientRect; + + /** The bounds of the current selection in screen coordinates. */ + public final @Nullable RectF screenRect; + + /** Set of valid actions available through {@link Selection#execute(String)} */ + public final @NonNull @SelectionActionDelegateAction Collection<String> availableActions; + + private final String mActionId; + + private final WeakReference<EventDispatcher> mEventDispatcher; + + /* package */ Selection( + final GeckoBundle bundle, + final @NonNull @SelectionActionDelegateAction Set<String> actions, + final EventDispatcher eventDispatcher) { + flags = + (bundle.getBoolean("collapsed") ? SelectionActionDelegate.FLAG_IS_COLLAPSED : 0) + | (bundle.getBoolean("editable") ? SelectionActionDelegate.FLAG_IS_EDITABLE : 0) + | (bundle.getBoolean("password") ? SelectionActionDelegate.FLAG_IS_PASSWORD : 0); + text = bundle.getString("selection"); + clientRect = bundle.getRectF("clientRect"); + screenRect = bundle.getRectF("screenRect"); + availableActions = actions; + mActionId = bundle.getString("actionId"); + mEventDispatcher = new WeakReference<>(eventDispatcher); + } + + /** Empty constructor for tests. */ + protected Selection() { + flags = 0; + text = ""; + clientRect = null; + screenRect = null; + availableActions = new HashSet<>(); + mActionId = null; + mEventDispatcher = null; + } + + /** + * Checks if the passed action is available + * + * @param action An {@link SelectionActionDelegate} to perform + * @return True if the action is available. + */ + @AnyThread + public boolean isActionAvailable( + @NonNull @SelectionActionDelegateAction final String action) { + return availableActions.contains(action); + } + + /** + * Execute an {@link SelectionActionDelegate} action. + * + * @throws IllegalStateException If the action was not available. + * @param action A {@link SelectionActionDelegate} action. + */ + @AnyThread + public void execute(@NonNull @SelectionActionDelegateAction final String action) { + if (!isActionAvailable(action)) { + throw new IllegalStateException("Action not available"); + } + final EventDispatcher eventDispatcher = mEventDispatcher.get(); + if (eventDispatcher == null) { + // The session is not available anymore, nothing really to do + Log.w(LOGTAG, "Calling execute on a stale Selection."); + return; + } + final GeckoBundle response = new GeckoBundle(2); + response.putString("id", action); + response.putString("actionId", mActionId); + eventDispatcher.dispatch("GeckoView:ExecuteSelectionAction", response); + } + + /** + * Hide selection actions and cause {@link #onHideAction} to be called. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void hide() { + execute(ACTION_HIDE); + } + + /** + * Copy onto the clipboard then delete the selected content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void cut() { + execute(ACTION_CUT); + } + + /** + * Copy the selected content onto the clipboard. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void copy() { + execute(ACTION_COPY); + } + + /** + * Delete the selected content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void delete() { + execute(ACTION_DELETE); + } + + /** + * Replace the selected content with the clipboard content. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void paste() { + execute(ACTION_PASTE); + } + + /** + * Replace the selected content with the clipboard content as plain text. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void pasteAsPlainText() { + execute(ACTION_PASTE_AS_PLAIN_TEXT); + } + + /** + * Select the entire content of the document or editor. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void selectAll() { + execute(ACTION_SELECT_ALL); + } + + /** + * Clear the current selection. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void unselect() { + execute(ACTION_UNSELECT); + } + + /** + * Collapse the current selection to its start position. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void collapseToStart() { + execute(ACTION_COLLAPSE_TO_START); + } + + /** + * Collapse the current selection to its end position. + * + * @throws IllegalStateException If the action was not available. + */ + @AnyThread + public void collapseToEnd() { + execute(ACTION_COLLAPSE_TO_END); + } + } + + /** + * Selection actions are available. Selection actions become available when the user selects + * some content in the document or editor. Inside an editor, selection actions can also become + * available when the user explicitly requests editor action UI, for example by tapping on the + * caret handle. + * + * <p>In response to this callback, applications typically display a toolbar containing the + * selection actions. To perform a certain action, check if the action is available with {@link + * Selection#isActionAvailable} then either use the relevant helper method or {@link + * Selection#execute} + * + * <p>Once an {@link #onHideAction} call (with particular reasons) or another {@link + * #onShowActionRequest} call is received, the previous Selection object is no longer usable. + * + * @param session The GeckoSession that initiated the callback. + * @param selection Current selection attributes and Callback object for performing built-in + * actions. May be used multiple times to perform multiple actions at once. + */ + @UiThread + default void onShowActionRequest( + @NonNull final GeckoSession session, @NonNull final Selection selection) {} + + /** Actions are no longer available due to the user clearing the selection. */ + final int HIDE_REASON_NO_SELECTION = 0; + /** + * Actions are no longer available due to the user moving the selection out of view. Previous + * actions are still available after a callback with this reason. + */ + final int HIDE_REASON_INVISIBLE_SELECTION = 1; + /** + * Actions are no longer available due to the user actively changing the selection. {@link + * #onShowActionRequest} may be called again once the user has set a selection, if the new + * selection has available actions. + */ + final int HIDE_REASON_ACTIVE_SELECTION = 2; + /** + * Actions are no longer available due to the user actively scrolling the page. {@link + * #onShowActionRequest} may be called again once the user has stopped scrolling the page, if + * the selection is still visible. Until then, previous actions are still available after a + * callback with this reason. + */ + final int HIDE_REASON_ACTIVE_SCROLL = 3; + + /** + * Previous actions are no longer available due to the user interacting with the page. + * Applications typically hide the action toolbar in response. + * + * @param session The GeckoSession that initiated the callback. + * @param reason The reason that actions are no longer available, as one of the {@link + * #HIDE_REASON_NO_SELECTION HIDE_REASON_*} constants. + */ + @UiThread + default void onHideAction( + @NonNull final GeckoSession session, @SelectionActionDelegateHideReason final int reason) {} + + /** + * Permission for reading clipboard data. See: <a + * href="https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText">Clipboard.readText()</a> + */ + int PERMISSION_CLIPBOARD_READ = 1; + + /** Represents attributes of a clipboard permission. */ + public class ClipboardPermission { + /** The URI associated with this content permission. */ + public final @NonNull String uri; + + /** + * The type of this permission; one of {@link #PERMISSION_CLIPBOARD_READ + * PERMISSION_CLIPBOARD_*}. + */ + public final @ClipboardPermissionType int type; + /** + * The last mouse or touch location in screen coordinates when the permission is requested. + */ + public final @Nullable Point screenPoint; + + /** Empty constructor for tests */ + protected ClipboardPermission() { + this.uri = ""; + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = null; + } + + private ClipboardPermission(final @NonNull GeckoBundle bundle) { + this.uri = bundle.getString("uri"); + this.type = PERMISSION_CLIPBOARD_READ; + this.screenPoint = bundle.getPoint("screenPoint"); + } + } + + /** + * Request clipboard permission. + * + * @param session The GeckoSession that initiated the callback. + * @param permission An {@link ClipboardPermission} describing the permission being requested. + * @return A {@link GeckoResult} with {@link AllowOrDeny}, determining the response to the + * permission request for this site. + */ + @UiThread + default @Nullable GeckoResult<AllowOrDeny> onShowClipboardPermissionRequest( + @NonNull final GeckoSession session, @NonNull ClipboardPermission permission) { + return GeckoResult.deny(); + } + + /** + * Dismiss requesting clipboard permission popup or model. + * + * @param session The GeckoSession that initiated the callback. + */ + @UiThread + default void onDismissClipboardPermissionRequest(@NonNull final GeckoSession session) {} + } + + @Retention(RetentionPolicy.SOURCE) + @StringDef({ + SelectionActionDelegate.ACTION_HIDE, + SelectionActionDelegate.ACTION_CUT, + SelectionActionDelegate.ACTION_COPY, + SelectionActionDelegate.ACTION_DELETE, + SelectionActionDelegate.ACTION_PASTE, + SelectionActionDelegate.ACTION_PASTE_AS_PLAIN_TEXT, + SelectionActionDelegate.ACTION_SELECT_ALL, + SelectionActionDelegate.ACTION_UNSELECT, + SelectionActionDelegate.ACTION_COLLAPSE_TO_START, + SelectionActionDelegate.ACTION_COLLAPSE_TO_END + }) + public @interface SelectionActionDelegateAction {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SelectionActionDelegate.FLAG_IS_COLLAPSED, + SelectionActionDelegate.FLAG_IS_EDITABLE, + SelectionActionDelegate.FLAG_IS_PASSWORD + }) + public @interface SelectionActionDelegateFlag {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SelectionActionDelegate.HIDE_REASON_NO_SELECTION, + SelectionActionDelegate.HIDE_REASON_INVISIBLE_SELECTION, + SelectionActionDelegate.HIDE_REASON_ACTIVE_SELECTION, + SelectionActionDelegate.HIDE_REASON_ACTIVE_SCROLL + }) + public @interface SelectionActionDelegateHideReason {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SelectionActionDelegate.PERMISSION_CLIPBOARD_READ, + }) + public @interface ClipboardPermissionType {} + + public interface NavigationDelegate { + /** + * A view has started loading content from the network. + * + * @param session The GeckoSession that initiated the callback. + * @param url The resource being loaded. + * @param perms The permissions currently associated with this url. + */ + @UiThread + default void onLocationChange( + @NonNull GeckoSession session, + @Nullable String url, + final @NonNull List<PermissionDelegate.ContentPermission> perms) {} + + /** + * The view's ability to go back has changed. + * + * @param session The GeckoSession that initiated the callback. + * @param canGoBack The new value for the ability. + */ + @UiThread + default void onCanGoBack(@NonNull final GeckoSession session, final boolean canGoBack) {} + + /** + * The view's ability to go forward has changed. + * + * @param session The GeckoSession that initiated the callback. + * @param canGoForward The new value for the ability. + */ + @UiThread + default void onCanGoForward(@NonNull final GeckoSession session, final boolean canGoForward) {} + + public static final int TARGET_WINDOW_NONE = 0; + public static final int TARGET_WINDOW_CURRENT = 1; + public static final int TARGET_WINDOW_NEW = 2; + + // Match with nsIWebNavigation.idl. + /** The load request was triggered by an HTTP redirect. */ + static final int LOAD_REQUEST_IS_REDIRECT = 0x800000; + + /** Load request details. */ + public static class LoadRequest { + /* package */ LoadRequest( + @NonNull final String uri, + @Nullable final String triggerUri, + final int geckoTarget, + final int flags, + final boolean hasUserGesture, + final boolean isDirectNavigation) { + this.uri = uri; + this.triggerUri = triggerUri; + this.target = convertGeckoTarget(geckoTarget); + this.isRedirect = (flags & LOAD_REQUEST_IS_REDIRECT) != 0; + this.hasUserGesture = hasUserGesture; + this.isDirectNavigation = isDirectNavigation; + } + + /** Empty constructor for tests. */ + protected LoadRequest() { + uri = ""; + triggerUri = null; + target = TARGET_WINDOW_NONE; + isRedirect = false; + hasUserGesture = false; + isDirectNavigation = false; + } + + // This needs to match nsIBrowserDOMWindow.idl + private @TargetWindow int convertGeckoTarget(final int geckoTarget) { + switch (geckoTarget) { + case 0: // OPEN_DEFAULTWINDOW + case 1: // OPEN_CURRENTWINDOW + return TARGET_WINDOW_CURRENT; + default: // OPEN_NEWWINDOW, OPEN_NEWTAB + return TARGET_WINDOW_NEW; + } + } + + /** The URI to be loaded. */ + public final @NonNull String uri; + + /** + * The URI of the origin page that triggered the load request. null for initial loads and + * loads originating from data: URIs. + */ + public final @Nullable String triggerUri; + + /** + * The target where the window has requested to open. One of {@link #TARGET_WINDOW_NONE + * TARGET_WINDOW_*}. + */ + public final @TargetWindow int target; + + /** + * True if and only if the request was triggered by an HTTP redirect. + * + * <p>If the user loads URI "a", which redirects to URI "b", then <code>onLoadRequest</code> + * will be called twice, first with uri "a" and <code>isRedirect = false</code>, then with uri + * "b" and <code>isRedirect = true</code>. + */ + public final boolean isRedirect; + + /** True if there was an active user gesture when the load was requested. */ + public final boolean hasUserGesture; + + /** + * This load request was initiated by a direct navigation from the application. E.g. when + * calling {@link GeckoSession#load}. + */ + public final boolean isDirectNavigation; + + @Override + public String toString() { + final StringBuilder out = new StringBuilder("LoadRequest { "); + out.append("uri: " + uri) + .append(", triggerUri: " + triggerUri) + .append(", target: " + target) + .append(", isRedirect: " + isRedirect) + .append(", hasUserGesture: " + hasUserGesture) + .append(", fromLoadUri: " + hasUserGesture) + .append(" }"); + return out.toString(); + } + } + + /** + * A request to open an URI. This is called before each top-level page load to allow custom + * behavior. For example, this can be used to override the behavior of TAGET_WINDOW_NEW + * requests, which defaults to requesting a new GeckoSession via onNewSession. + * + * @param session The GeckoSession that initiated the callback. + * @param request The {@link LoadRequest} containing the request details. + * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not + * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a + * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is + * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled). + */ + @UiThread + default @Nullable GeckoResult<AllowOrDeny> onLoadRequest( + @NonNull final GeckoSession session, @NonNull final LoadRequest request) { + return null; + } + + /** + * A request to load a URI in a non-top-level context. + * + * @param session The GeckoSession that initiated the callback. + * @param request The {@link LoadRequest} containing the request details. + * @return A {@link GeckoResult} with a {@link AllowOrDeny} value which indicates whether or not + * the load was handled. If unhandled, Gecko will continue the load as normal. If handled (a + * {@link AllowOrDeny#DENY DENY} value), Gecko will abandon the load. A null return value is + * interpreted as {@link AllowOrDeny#ALLOW ALLOW} (unhandled). + */ + @UiThread + default @Nullable GeckoResult<AllowOrDeny> onSubframeLoadRequest( + @NonNull final GeckoSession session, @NonNull final LoadRequest request) { + return null; + } + + /** + * A request has been made to open a new session. The URI is provided only for informational + * purposes. Do not call GeckoSession.load here. Additionally, the returned GeckoSession must be + * a newly-created one. + * + * @param session The GeckoSession that initiated the callback. + * @param uri The URI to be loaded. + * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which + * case the request for a new window by web content will fail. e.g., <code>window.open() + * </code> will return null. The implementation of onNewSession is responsible for + * maintaining a reference to the returned object, to prevent it from being garbage + * collected. + */ + @UiThread + default @Nullable GeckoResult<GeckoSession> onNewSession( + @NonNull final GeckoSession session, @NonNull final String uri) { + return null; + } + + /** + * @param session The GeckoSession that initiated the callback. + * @param uri The URI that failed to load. + * @param error A WebRequestError containing details about the error + * @return A URI to display as an error. Returning null will halt the load entirely. The + * following special methods are made available to the URI: - + * document.addCertException(isTemporary), returns Promise - + * document.getFailedCertSecurityInfo(), returns FailedCertSecurityInfo - + * document.getNetErrorInfo(), returns NetErrorInfo - document.allowDeprecatedTls, a + * property indicating whether or not TLS 1.0/1.1 is allowed - + * document.reloadWithHttpsOnlyException() + * @see <a + * href="https://searchfox.org/mozilla-central/source/dom/webidl/FailedCertSecurityInfo.webidl">FailedCertSecurityInfo + * IDL</a> + * @see <a + * href="https://searchfox.org/mozilla-central/source/dom/webidl/NetErrorInfo.webidl">NetErrorInfo + * IDL</a> + */ + @UiThread + default @Nullable GeckoResult<String> onLoadError( + @NonNull final GeckoSession session, + @Nullable final String uri, + @NonNull final WebRequestError error) { + return null; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + NavigationDelegate.TARGET_WINDOW_NONE, + NavigationDelegate.TARGET_WINDOW_CURRENT, + NavigationDelegate.TARGET_WINDOW_NEW + }) + public @interface TargetWindow {} + + /** + * GeckoSession applications implement this interface to handle prompts triggered by content in + * the GeckoSession, such as alerts, authentication dialogs, and select list pickers. + */ + public interface PromptDelegate { + /** PromptResponse is an opaque class created upon confirming or dismissing a prompt. */ + public class PromptResponse { + private final BasePrompt mPrompt; + + /* package */ PromptResponse(@NonNull final BasePrompt prompt) { + mPrompt = prompt; + } + + /* package */ void dispatch(@NonNull final EventCallback callback) { + if (mPrompt == null) { + throw new RuntimeException("Trying to confirm/dismiss a null prompt."); + } + mPrompt.dispatch(callback); + } + } + + interface PromptInstanceDelegate { + /** + * Called when this prompt has been dismissed by the system. + * + * <p>This can happen e.g. when the page navigates away and the content of the prompt is not + * relevant anymore. + * + * <p>When this method is called, you should hide the prompt UI elements. + * + * @param prompt the prompt that should be dismissed. + */ + @UiThread + default void onPromptDismiss(final @NonNull BasePrompt prompt) {} + + /** + * Called when this prompt has been updated. + * + * <p>This is called if inner <option> elements are updated when using <select> + * element. + * + * <p>When this method is called, you should update the prompt UI elements. + * + * @param prompt the new prompt that should be updated. + */ + @UiThread + default void onPromptUpdate(final @NonNull BasePrompt prompt) {} + } + + // Prompt classes. + public class BasePrompt { + private boolean mIsCompleted; + private boolean mIsConfirmed; + private GeckoBundle mResult; + private final WeakReference<Observer> mObserver; + private PromptInstanceDelegate mDelegate; + + protected interface Observer { + @AnyThread + default void onPromptCompleted(@NonNull BasePrompt prompt) {} + } + + private void complete() { + mIsCompleted = true; + final Observer observer = mObserver.get(); + if (observer != null) { + observer.onPromptCompleted(this); + } + } + + /** The title of this prompt; may be null. */ + public final @Nullable String title; + /* package */ String id; + + private BasePrompt( + @NonNull final String id, @Nullable final String title, final Observer observer) { + this.title = title; + this.id = id; + mIsConfirmed = false; + mIsCompleted = false; + mObserver = new WeakReference<>(observer); + } + + @UiThread + protected @NonNull PromptResponse confirm() { + if (mIsCompleted) { + throw new RuntimeException("Cannot confirm/dismiss a Prompt twice."); + } + + mIsConfirmed = true; + complete(); + return new PromptResponse(this); + } + + /** + * This dismisses the prompt without sending any meaningful information back to content. + * + * @return A {@link PromptResponse} with which you can complete the {@link GeckoResult} that + * corresponds to this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + if (mIsCompleted) { + throw new RuntimeException("Cannot confirm/dismiss a Prompt twice."); + } + + complete(); + return new PromptResponse(this); + } + + /** + * Set the delegate for this prompt. + * + * @param delegate the {@link PromptInstanceDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable PromptInstanceDelegate delegate) { + mDelegate = delegate; + } + + /** + * Get the delegate for this prompt. + * + * @return the {@link PromptInstanceDelegate} instance. + */ + @UiThread + @Nullable + public PromptInstanceDelegate getDelegate() { + return mDelegate; + } + + /* package */ GeckoBundle ensureResult() { + if (mResult == null) { + // Usually result object contains two items. + mResult = new GeckoBundle(2); + } + return mResult; + } + + /** + * This returns true if the prompt has already been confirmed or dismissed. + * + * @return A boolean which is true if the prompt has been confirmed or dismissed, and false + * otherwise. + */ + @UiThread + public boolean isComplete() { + return mIsCompleted; + } + + /* package */ void dispatch(@NonNull final EventCallback callback) { + if (!mIsCompleted) { + throw new RuntimeException("Trying to dispatch an incomplete prompt."); + } + + if (!mIsConfirmed) { + callback.sendSuccess(null); + } else { + callback.sendSuccess(mResult); + } + } + } + + /** + * BeforeUnloadPrompt represents the onbeforeunload prompt. See + * https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload + */ + class BeforeUnloadPrompt extends BasePrompt { + protected BeforeUnloadPrompt(@NonNull final String id, @NonNull final Observer observer) { + super(id, null, observer); + } + + /** + * Confirms the prompt. + * + * @param allowOrDeny whether the navigation should be allowed to continue or not. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) { + ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY); + return super.confirm(); + } + } + + /** + * RepostConfirmPrompt represents a prompt shown whenever the browser needs to resubmit POST + * data (e.g. due to page refresh). + */ + class RepostConfirmPrompt extends BasePrompt { + protected RepostConfirmPrompt(@NonNull final String id, @NonNull final Observer observer) { + super(id, null, observer); + } + + /** + * Confirms the prompt. + * + * @param allowOrDeny whether the browser should allow resubmitting data. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @Nullable AllowOrDeny allowOrDeny) { + ensureResult().putBoolean("allow", allowOrDeny != AllowOrDeny.DENY); + return super.confirm(); + } + } + + /** + * AlertPrompt contains the information necessary to represent a JavaScript alert() call from + * content; it can only be dismissed, not confirmed. + */ + public class AlertPrompt extends BasePrompt { + /** The message to be displayed with this alert; may be null. */ + public final @Nullable String message; + + protected AlertPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + } + } + + /** + * ButtonPrompt contains the information necessary to represent a JavaScript confirm() call from + * content. + */ + public class ButtonPrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.POSITIVE, Type.NEGATIVE}) + public @interface ButtonType {} + + public static class Type { + /** Index of positive response button (eg, "Yes", "OK") */ + public static final int POSITIVE = 0; + + /** Index of negative response button (eg, "No", "Cancel") */ + public static final int NEGATIVE = 2; + + protected Type() {} + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + protected ButtonPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + } + + /** + * Confirms this prompt, returning the selected button to content. + * + * @param selection An int representing the selected button, must be one of {@link Type}. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@ButtonType final int selection) { + ensureResult().putInt("button", selection); + return super.confirm(); + } + } + + /** + * TextPrompt contains the information necessary to represent a Javascript prompt() call from + * content. + */ + public class TextPrompt extends BasePrompt { + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** The default value for the text field; may be null. */ + public final @Nullable String defaultValue; + + protected TextPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @Nullable final String defaultValue, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.defaultValue = defaultValue; + } + + /** + * Confirms this prompt, returning the input text to content. + * + * @param text A String containing the text input given by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String text) { + ensureResult().putString("text", text); + return super.confirm(); + } + } + + /** + * AuthPrompt contains the information necessary to represent an HTML authorization prompt + * generated by content. + */ + public class AuthPrompt extends BasePrompt { + public static class AuthOptions { + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Flags.HOST, + Flags.PROXY, + Flags.ONLY_PASSWORD, + Flags.PREVIOUS_FAILED, + Flags.CROSS_ORIGIN_SUB_RESOURCE + }) + public @interface AuthFlag {} + + /** Auth prompt flags. */ + public static class Flags { + /** The auth prompt is for a network host. */ + public static final int HOST = 1 << 0; + /** The auth prompt is for a proxy. */ + public static final int PROXY = 1 << 1; + /** The auth prompt should only request a password. */ + public static final int ONLY_PASSWORD = 1 << 3; + /** The auth prompt is the result of a previous failed login. */ + public static final int PREVIOUS_FAILED = 1 << 4; + /** The auth prompt is for a cross-origin sub-resource. */ + public static final int CROSS_ORIGIN_SUB_RESOURCE = 1 << 5; + + protected Flags() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Level.NONE, Level.PW_ENCRYPTED, Level.SECURE}) + public @interface AuthLevel {} + + /** Auth prompt levels. */ + public static class Level { + /** The auth request is unencrypted or the encryption status is unknown. */ + public static final int NONE = 0; + /** The auth request only encrypts password but not data. */ + public static final int PW_ENCRYPTED = 1; + /** The auth request encrypts both password and data. */ + public static final int SECURE = 2; + + protected Level() {} + } + + /** An int bit-field of {@link Flags}. */ + public @AuthFlag final int flags; + + /** A string containing the URI for the auth request or null if unknown. */ + public @Nullable final String uri; + + /** An int, one of {@link Level}, indicating level of encryption. */ + public @AuthLevel final int level; + + /** A string containing the initial username or null if password-only. */ + public @Nullable final String username; + + /** A string containing the initial password. */ + public @Nullable final String password; + + /* package */ AuthOptions(final GeckoBundle options) { + flags = options.getInt("flags"); + uri = options.getString("uri"); + level = options.getInt("level"); + username = options.getString("username"); + password = options.getString("password"); + } + + /** Empty constructor for tests */ + protected AuthOptions() { + flags = 0; + uri = ""; + level = Level.NONE; + username = ""; + password = ""; + } + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** The {@link AuthOptions} that describe the type of authorization prompt. */ + public final @NonNull AuthOptions authOptions; + + protected AuthPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @NonNull final AuthOptions authOptions, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.authOptions = authOptions; + } + + /** + * Confirms this prompt with just a password, returning the password to content. + * + * @param password A String containing the password input by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String password) { + ensureResult().putString("password", password); + return super.confirm(); + } + + /** + * Confirms this prompt with a username and password, returning both to content. + * + * @param username A String containing the username input by the user. + * @param password A String containing the password input by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final String username, @NonNull final String password) { + ensureResult().putString("username", username); + ensureResult().putString("password", password); + return super.confirm(); + } + } + + /** + * ChoicePrompt contains the information necessary to display a menu or list prompt generated by + * content. + */ + public class ChoicePrompt extends BasePrompt { + public static class Choice { + /** + * A boolean indicating if the item is disabled. Item should not be selectable if this is + * true. + */ + public final boolean disabled; + + /** + * A String giving the URI of the item icon, or null if none exists (only valid for menus) + */ + public final @Nullable String icon; + + /** A String giving the ID of the item or group */ + public final @NonNull String id; + + /** A Choice array of sub-items in a group, or null if not a group */ + public final @Nullable Choice[] items; + + /** A string giving the label for displaying the item or group */ + public final @NonNull String label; + + /** A boolean indicating if the item should be pre-selected (pre-checked for menu items) */ + public final boolean selected; + + /** A boolean indicating if the item should be a menu separator (only valid for menus) */ + public final boolean separator; + + /* package */ Choice(final GeckoBundle choice) { + disabled = choice.getBoolean("disabled"); + icon = choice.getString("icon"); + id = choice.getString("id"); + label = choice.getString("label"); + selected = choice.getBoolean("selected"); + separator = choice.getBoolean("separator"); + + final GeckoBundle[] choices = choice.getBundleArray("items"); + if (choices == null) { + items = null; + } else { + items = new Choice[choices.length]; + for (int i = 0; i < choices.length; i++) { + items[i] = new Choice(choices[i]); + } + } + } + + /** Empty constructor for tests. */ + protected Choice() { + disabled = false; + icon = ""; + id = ""; + label = ""; + selected = false; + separator = false; + items = null; + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.MENU, Type.SINGLE, Type.MULTIPLE}) + public @interface ChoiceType {} + + public static class Type { + /** Display choices in a menu that dismisses as soon as an item is chosen. */ + public static final int MENU = 1; + + /** Display choices in a list that allows a single selection. */ + public static final int SINGLE = 2; + + /** Display choices in a list that allows multiple selections. */ + public static final int MULTIPLE = 3; + + protected Type() {} + } + + /** The message to be displayed with this prompt; may be null. */ + public final @Nullable String message; + + /** One of {@link Type}. */ + public final @ChoiceType int type; + + /** An array of {@link Choice} representing possible choices. */ + public final @NonNull Choice[] choices; + + protected ChoicePrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String message, + @ChoiceType final int type, + @NonNull final Choice[] choices, + @NonNull final Observer observer) { + super(id, title, observer); + this.message = message; + this.type = type; + this.choices = choices; + } + + /** + * Confirms this prompt with the string id of a single choice. + * + * @param selectedId The string ID of the selected choice. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String selectedId) { + return confirm(new String[] {selectedId}); + } + + /** + * Confirms this prompt with the string ids of multiple choices + * + * @param selectedIds The string IDs of the selected choices. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String[] selectedIds) { + if ((Type.MENU == type || Type.SINGLE == type) + && (selectedIds == null || selectedIds.length != 1)) { + throw new IllegalArgumentException(); + } + ensureResult().putStringArray("choices", selectedIds); + return super.confirm(); + } + + /** + * Confirms this prompt with a single choice. + * + * @param selectedChoice The selected choice. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final Choice selectedChoice) { + return confirm(selectedChoice == null ? null : selectedChoice.id); + } + + /** + * Confirms this prompt with multiple choices. + * + * @param selectedChoices The selected choices. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final Choice[] selectedChoices) { + if ((Type.MENU == type || Type.SINGLE == type) + && (selectedChoices == null || selectedChoices.length != 1)) { + throw new IllegalArgumentException(); + } + + if (selectedChoices == null) { + return confirm((String[]) null); + } + + final String[] ids = new String[selectedChoices.length]; + for (int i = 0; i < ids.length; i++) { + ids[i] = (selectedChoices[i] == null) ? null : selectedChoices[i].id; + } + + return confirm(ids); + } + } + + /** + * ColorPrompt contains the information necessary to represent a prompt for color input + * generated by content. + */ + public class ColorPrompt extends BasePrompt { + /** The default value supplied by content. */ + public final @Nullable String defaultValue; + + /** The predefined values by <datalist> element */ + public final @Nullable String[] predefinedValues; + + protected ColorPrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String defaultValue, + @Nullable final String[] predefinedValues, + @NonNull final Observer observer) { + super(id, title, observer); + this.defaultValue = defaultValue; + this.predefinedValues = predefinedValues; + } + + /** + * Confirms the prompt and passes the color value back to content. + * + * @param color A String representing the color to be returned to content. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String color) { + ensureResult().putString("color", color); + return super.confirm(); + } + } + + /** + * DateTimePrompt contains the information necessary to represent a prompt for date and/or time + * input generated by content. + */ + public class DateTimePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.DATE, Type.MONTH, Type.WEEK, Type.TIME, Type.DATETIME_LOCAL}) + public @interface DatetimeType {} + + public static class Type { + /** Prompt for year, month, and day. */ + public static final int DATE = 1; + + /** Prompt for year and month. */ + public static final int MONTH = 2; + + /** Prompt for year and week. */ + public static final int WEEK = 3; + + /** Prompt for hour and minute. */ + public static final int TIME = 4; + + /** Prompt for year, month, day, hour, and minute, without timezone. */ + public static final int DATETIME_LOCAL = 5; + + protected Type() {} + } + + /** One of {@link Type} indicating the type of prompt. */ + public final @DatetimeType int type; + + /** A String representing the default value supplied by content. */ + public final @Nullable String defaultValue; + + /** A String representing the minimum value allowed by content. */ + public final @Nullable String minValue; + + /** A String representing the maximum value allowed by content. */ + public final @Nullable String maxValue; + + /** A String representing the step value allowed by content. */ + public final @Nullable String stepValue; + + /** For testing. */ + private DateTimePrompt() { + // Initialize final members + super("", null, null); + this.type = Type.DATE; + this.defaultValue = null; + this.minValue = null; + this.maxValue = null; + this.stepValue = null; + } + + /* package */ DateTimePrompt( + @NonNull final String id, + @Nullable final String title, + @DatetimeType final int type, + @Nullable final String defaultValue, + @Nullable final String minValue, + @Nullable final String maxValue, + @Nullable final String stepValue, + @NonNull final Observer observer) { + super(id, title, observer); + this.type = type; + this.defaultValue = defaultValue; + this.minValue = minValue; + this.maxValue = maxValue; + this.stepValue = stepValue; + } + + /** + * Confirms the prompt and passes the date and/or time value back to content. + * + * @param datetime A String representing the date and time to be returned to content. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final String datetime) { + ensureResult().putString("datetime", datetime); + return super.confirm(); + } + } + + /** + * FilePrompt contains the information necessary to represent a prompt for a file or files + * generated by content. + */ + public class FilePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Type.SINGLE, Type.MULTIPLE}) + public @interface FileType {} + + /** Types of file prompts. */ + public static class Type { + /** Prompt for a single file. */ + public static final int SINGLE = 1; + + /** Prompt for multiple files. */ + public static final int MULTIPLE = 2; + + protected Type() {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({Capture.NONE, Capture.ANY, Capture.USER, Capture.ENVIRONMENT}) + public @interface CaptureType {} + + /** Possible capture attribute values. */ + public static class Capture { + // These values should match the corresponding values in nsIFilePicker.idl + /** No capture attribute has been supplied by content. */ + public static final int NONE = 0; + + /** The capture attribute was supplied with a missing or invalid value. */ + public static final int ANY = 1; + + /** The "user" capture attribute has been supplied by content. */ + public static final int USER = 2; + + /** The "environment" capture attribute has been supplied by content. */ + public static final int ENVIRONMENT = 3; + + protected Capture() {} + } + + /** One of {@link Type} indicating the prompt type. */ + public final @FileType int type; + + /** + * An array of Strings giving the MIME types specified by the "accept" attribute, if any are + * specified. + */ + public final @Nullable String[] mimeTypes; + + /** One of {@link Capture} indicating the capture attribute supplied by content. */ + public final @CaptureType int capture; + + protected FilePrompt( + @NonNull final String id, + @Nullable final String title, + @FileType final int type, + @CaptureType final int capture, + @Nullable final String[] mimeTypes, + @NonNull final Observer observer) { + super(id, title, observer); + this.type = type; + this.capture = capture; + this.mimeTypes = mimeTypes; + } + + /** + * Confirms the prompt and passes the file URI back to content. + * + * @param context An Application context for parsing URIs. + * @param uri The URI of the file chosen by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final Context context, @NonNull final Uri uri) { + return confirm(context, new Uri[] {uri}); + } + + /** + * Confirms the prompt and passes the file URIs back to content. + * + * @param context An Application context for parsing URIs. + * @param uris The URIs of the files chosen by the user. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm( + @NonNull final Context context, @NonNull final Uri[] uris) { + if (Type.SINGLE == type && (uris == null || uris.length != 1)) { + throw new IllegalArgumentException(); + } + + final String[] paths = new String[uris != null ? uris.length : 0]; + for (int i = 0; i < paths.length; i++) { + paths[i] = getFile(context, uris[i]); + if (paths[i] == null) { + Log.e(LOGTAG, "Only file URIs are supported: " + uris[i]); + } + } + ensureResult().putStringArray("files", paths); + + return super.confirm(); + } + + private static String getFile(final @NonNull Context context, final @NonNull Uri uri) { + if (uri == null) { + return null; + } + if ("file".equals(uri.getScheme())) { + return uri.getPath(); + } + final ContentResolver cr = context.getContentResolver(); + final Cursor cur = + cr.query( + uri, + new String[] {"_data"}, /* selection */ + null, + /* args */ null, /* sort */ + null); + if (cur == null) { + return null; + } + try { + final int idx = cur.getColumnIndex("_data"); + if (idx < 0 || !cur.moveToFirst()) { + return null; + } + do { + try { + final String path = cur.getString(idx); + if (path != null && !path.isEmpty()) { + return path; + } + } catch (final Exception e) { + } + } while (cur.moveToNext()); + } finally { + cur.close(); + } + return null; + } + } + + /** PopupPrompt contains the information necessary to represent a popup blocking request. */ + public class PopupPrompt extends BasePrompt { + /** The target URI for the popup; may be null. */ + public final @Nullable String targetUri; + + protected PopupPrompt( + @NonNull final String id, + @Nullable final String targetUri, + @NonNull final Observer observer) { + super(id, null, observer); + this.targetUri = targetUri; + } + + /** + * Confirms the prompt and either allows or blocks the popup. + * + * @param response An {@link AllowOrDeny} specifying whether to allow or deny the popup. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@NonNull final AllowOrDeny response) { + boolean res = false; + if (AllowOrDeny.ALLOW == response) { + res = true; + } + ensureResult().putBoolean("response", res); + return super.confirm(); + } + } + + /** SharePrompt contains the information necessary to represent a (v1) WebShare request. */ + public class SharePrompt extends BasePrompt { + @Retention(RetentionPolicy.SOURCE) + @IntDef({Result.SUCCESS, Result.FAILURE, Result.ABORT}) + public @interface ShareResult {} + + /** Possible results to a {@link SharePrompt}. */ + public static class Result { + /** The user shared with another app successfully. */ + public static final int SUCCESS = 0; + + /** The user attempted to share with another app, but it failed. */ + public static final int FAILURE = 1; + + /** The user aborted the share. */ + public static final int ABORT = 2; + + protected Result() {} + } + + /** The text for the share request. */ + public final @Nullable String text; + + /** The uri for the share request. */ + public final @Nullable String uri; + + protected SharePrompt( + @NonNull final String id, + @Nullable final String title, + @Nullable final String text, + @Nullable final String uri, + @NonNull final Observer observer) { + super(id, title, observer); + this.text = text; + this.uri = uri; + } + + /** + * Confirms the prompt and either blocks or allows the share request. + * + * @param response One of {@link Result} specifying the outcome of the share attempt. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(@ShareResult final int response) { + ensureResult().putInt("response", response); + return super.confirm(); + } + + /** + * Dismisses the prompt and returns {@link Result#ABORT} to web content. + * + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + ensureResult().putInt("response", Result.ABORT); + return super.dismiss(); + } + } + + /** Request containing information required to resolve Autocomplete prompt requests. */ + public class AutocompleteRequest<T extends Autocomplete.Option<?>> extends BasePrompt { + /** + * The Autocomplete options for this request. This can contain a single or multiple entries. + */ + public final @NonNull T[] options; + + protected AutocompleteRequest( + final @NonNull String id, final @NonNull T[] options, final Observer observer) { + super(id, null, observer); + this.options = options; + } + + /** + * Confirm the request by responding with a selection. See the PromptDelegate callbacks for + * specifics. + * + * @param selection The {@link Autocomplete.Option} used to confirm the request. + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse confirm(final @NonNull Autocomplete.Option<?> selection) { + ensureResult().putBundle("selection", selection.toBundle()); + return super.confirm(); + } + + /** + * Dismiss the request. See the PromptDelegate callbacks for specifics. + * + * @return A {@link PromptResponse} which can be used to complete the {@link GeckoResult} + * associated with this prompt. + */ + @UiThread + public @NonNull PromptResponse dismiss() { + return super.dismiss(); + } + } + + // Delegate functions. + /** + * Display an alert prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link AlertPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onAlertPrompt( + @NonNull final GeckoSession session, @NonNull final AlertPrompt prompt) { + return null; + } + + /** + * Display a onbeforeunload prompt. + * + * <p>See https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload + * See {@link BeforeUnloadPrompt} + * + * @param session GeckoSession that triggered the prompt + * @param prompt the {@link BeforeUnloadPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed + * to continue with the navigation or {@link AllowOrDeny#DENY} otherwise. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onBeforeUnloadPrompt( + @NonNull final GeckoSession session, @NonNull final BeforeUnloadPrompt prompt) { + return null; + } + + /** + * Display a POST resubmission confirmation prompt. + * + * <p>This prompt will trigger whenever refreshing or navigating to a page needs resubmitting + * POST data that has been submitted already. + * + * @param session GeckoSession that triggered the prompt + * @param prompt the {@link RepostConfirmPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to {@link AllowOrDeny#ALLOW} if the page is allowed + * to continue with the navigation and resubmit the POST data or {@link AllowOrDeny#DENY} + * otherwise. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onRepostConfirmPrompt( + @NonNull final GeckoSession session, @NonNull final RepostConfirmPrompt prompt) { + return null; + } + + /** + * Display a button prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link ButtonPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onButtonPrompt( + @NonNull final GeckoSession session, @NonNull final ButtonPrompt prompt) { + return null; + } + + /** + * Display a text prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link TextPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onTextPrompt( + @NonNull final GeckoSession session, @NonNull final TextPrompt prompt) { + return null; + } + + /** + * Display an authorization prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link AuthPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onAuthPrompt( + @NonNull final GeckoSession session, @NonNull final AuthPrompt prompt) { + return null; + } + + /** + * Display a list/menu prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link ChoicePrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onChoicePrompt( + @NonNull final GeckoSession session, @NonNull final ChoicePrompt prompt) { + return null; + } + + /** + * Display a color prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link ColorPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onColorPrompt( + @NonNull final GeckoSession session, @NonNull final ColorPrompt prompt) { + return null; + } + + /** + * Display a date/time prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link DateTimePrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onDateTimePrompt( + @NonNull final GeckoSession session, @NonNull final DateTimePrompt prompt) { + return null; + } + + /** + * Display a file prompt. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link FilePrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onFilePrompt( + @NonNull final GeckoSession session, @NonNull final FilePrompt prompt) { + return null; + } + + /** + * Display a popup request prompt; this occurs when content attempts to open a new window in a + * way that doesn't appear to be the result of user input. + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link PopupPrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onPopupPrompt( + @NonNull final GeckoSession session, @NonNull final PopupPrompt prompt) { + return null; + } + + /** + * Display a share request prompt; this occurs when content attempts to use the WebShare API. + * See: https://developer.mozilla.org/en-US/docs/Web/API/Navigator/share + * + * @param session GeckoSession that triggered the prompt. + * @param prompt The {@link SharePrompt} that describes the prompt. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} which includes all + * necessary information to resolve the prompt. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onSharePrompt( + @NonNull final GeckoSession session, @NonNull final SharePrompt prompt) { + return null; + } + + /** + * Handle a login save prompt request. This is triggered by the user entering new or modified + * login credentials into a login form. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse}. + * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link + * Autocomplete.StorageDelegate#onLoginSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created login entry. + * <p>Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onLoginSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.LoginSaveOption> request) { + return null; + } + + /** + * Handle a address save prompt request. This is triggered by the user entering new or modified + * address credentials into a address form. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse}. + * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link + * Autocomplete.StorageDelegate#onAddressSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created address entry. + * <p>Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onAddressSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.AddressSaveOption> request) { + return null; + } + + /** + * Handle a credit card save prompt request. This is triggered by the user entering new or + * modified credit card credentials into a form. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse}. + * <p>Confirm the request with an {@link Autocomplete.Option} to trigger a {@link + * Autocomplete.StorageDelegate#onCreditCardSave} request to save the given selection. The + * confirmed selection may be an entry out of the request's options, a modified option, or a + * freshly created credit card entry. + * <p>Dismiss the request to deny the saving request. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onCreditCardSave( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.CreditCardSaveOption> request) { + return null; + } + + /** + * Handle a login selection prompt request. This is triggered by the user focusing on a login + * username field. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} + * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the + * login forms with the given selection details. The confirmed selection may be an entry out + * of the request's options, a modified option, or a freshly created login entry. + * <p>Dismiss the request to deny autocompletion for the detected form. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onLoginSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.LoginSelectOption> request) { + return null; + } + + /** + * Handle a credit card selection prompt request. This is triggered by the user focusing on a + * credit card input field. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} + * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the + * credit card forms with the given selection details. The confirmed selection may be an + * entry out of the request's options, a modified option, or a freshly created credit card + * entry. + * <p>Dismiss the request to deny autocompletion for the detected form. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onCreditCardSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.CreditCardSelectOption> request) { + return null; + } + + /** + * Handle a address selection prompt request. This is triggered by the user focusing on a + * address field. + * + * @param session The {@link GeckoSession} that triggered the request. + * @param request The {@link AutocompleteRequest} containing the request details. + * @return A {@link GeckoResult} resolving to a {@link PromptResponse} + * <p>Confirm the request with an {@link Autocomplete.Option} to let GeckoView fill out the + * address forms with the given selection details. The confirmed selection may be an entry + * out of the request's options, a modified option, or a freshly created address entry. + * <p>Dismiss the request to deny autocompletion for the detected form. + */ + @UiThread + default @Nullable GeckoResult<PromptResponse> onAddressSelect( + @NonNull final GeckoSession session, + @NonNull final AutocompleteRequest<Autocomplete.AddressSelectOption> request) { + return null; + } + } + + /** GeckoSession applications implement this interface to handle content scroll events. */ + public interface ScrollDelegate { + /** + * The scroll position of the content has changed. + * + * @param session GeckoSession that initiated the callback. + * @param scrollX The new horizontal scroll position in pixels. + * @param scrollY The new vertical scroll position in pixels. + */ + @UiThread + default void onScrollChanged( + @NonNull final GeckoSession session, final int scrollX, final int scrollY) {} + } + + /** + * Get the PanZoomController instance for this session. + * + * @return PanZoomController instance. + */ + @UiThread + public @NonNull PanZoomController getPanZoomController() { + ThreadUtils.assertOnUiThread(); + + return mPanZoomController; + } + + /** + * Get the OverscrollEdgeEffect instance for this session. + * + * @return OverscrollEdgeEffect instance. + */ + @UiThread + public @NonNull OverscrollEdgeEffect getOverscrollEdgeEffect() { + ThreadUtils.assertOnUiThread(); + + if (mOverscroll == null) { + mOverscroll = new OverscrollEdgeEffect(); + } + return mOverscroll; + } + + /** + * Get the CompositorController instance for this session. + * + * @return CompositorController instance. + */ + @UiThread + public @NonNull CompositorController getCompositorController() { + ThreadUtils.assertOnUiThread(); + + if (mController == null) { + mController = new CompositorController(this); + if (mCompositorReady) { + mController.onCompositorReady(); + } + } + return mController; + } + + /** + * Get a matrix for transforming from client coordinates to surface coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToScreenMatrix(Matrix) + * @see #getPageToSurfaceMatrix(Matrix) + */ + @UiThread + public void getClientToSurfaceMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + matrix.setScale(mViewportZoom, mViewportZoom); + if (mClientTop != mTop) { + matrix.postTranslate(0, mClientTop - mTop); + } + } + + /** + * Get a matrix for transforming from client coordinates to screen coordinates. The client + * coordinates are in CSS pixels and are relative to the viewport origin; their relation to screen + * coordinates does not depend on the current scroll position. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToSurfaceMatrix(Matrix) + * @see #getPageToScreenMatrix(Matrix) + */ + @UiThread + public void getClientToScreenMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getClientToSurfaceMatrix(matrix); + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get a matrix for transforming from page coordinates to screen coordinates. The page coordinates + * are in CSS pixels and are relative to the page origin; their relation to screen coordinates + * depends on the current scroll position of the outermost frame. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getPageToSurfaceMatrix(Matrix) + * @see #getClientToScreenMatrix(Matrix) + */ + @UiThread + public void getPageToScreenMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getPageToSurfaceMatrix(matrix); + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get a matrix for transforming from page coordinates to surface coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getPageToScreenMatrix(Matrix) + * @see #getClientToSurfaceMatrix(Matrix) + */ + @UiThread + public void getPageToSurfaceMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + getClientToSurfaceMatrix(matrix); + matrix.postTranslate(-mViewportLeft, -mViewportTop); + } + + /** + * Get a matrix for transforming from layout device client coordinates to screen coordinates. + * + * @param matrix Matrix to be replaced by the transformation matrix. + * @see #getClientToScreenMatrix(Matrix) + * @see #getPageToSurfaceMatrix(Matrix) + */ + @UiThread + /* package */ void getClientToScreenOffsetMatrix(@NonNull final Matrix matrix) { + ThreadUtils.assertOnUiThread(); + + matrix.postTranslate(mLeft, mTop); + } + + /** + * Get the bounds of the client area in client coordinates. The returned top-left coordinates are + * always (0, 0). Use the matrix from {@link #getClientToSurfaceMatrix(Matrix)} or {@link + * #getClientToScreenMatrix(Matrix)} to map these bounds to surface or screen coordinates, + * respectively. + * + * @param rect RectF to be replaced by the client bounds in client coordinates. + * @see #getSurfaceBounds(Rect) + */ + @UiThread + public void getClientBounds(@NonNull final RectF rect) { + ThreadUtils.assertOnUiThread(); + + rect.set(0.0f, 0.0f, (float) mWidth / mViewportZoom, (float) mClientHeight / mViewportZoom); + } + + /** + * Get the bounds of the client area in surface coordinates. This is equivalent to mapping the + * bounds returned by #getClientBounds(RectF) with the matrix returned by + * #getClientToSurfaceMatrix(Matrix). + * + * @param rect Rect to be replaced by the client bounds in surface coordinates. + */ + @UiThread + public void getSurfaceBounds(@NonNull final Rect rect) { + ThreadUtils.assertOnUiThread(); + + rect.set(0, mClientTop - mTop, mWidth, mHeight); + } + + /** + * GeckoSession applications implement this interface to handle requests for permissions from + * content, such as geolocation and notifications. For each permission, usually two requests are + * generated: one request for the Android app permission through requestAppPermissions, which is + * typically handled by a system permission dialog; and another request for the content permission + * (e.g. through requestContentPermission), which is typically handled by an app-specific + * permission dialog. + * + * <p>When denying an Android app permission, the response is not stored by GeckoView. It is the + * responsibility of the consumer to store the response state and therefore prevent further + * requests from being presented to the user. + */ + public interface PermissionDelegate { + /** + * Permission for using the geolocation API. See: + * https://developer.mozilla.org/en-US/docs/Web/API/Geolocation + */ + int PERMISSION_GEOLOCATION = 0; + + /** + * Permission for using the notifications API. See: + * https://developer.mozilla.org/en-US/docs/Web/API/notification + */ + int PERMISSION_DESKTOP_NOTIFICATION = 1; + + /** + * Permission for using the storage API. See: + * https://developer.mozilla.org/en-US/docs/Web/API/Storage_API + */ + int PERMISSION_PERSISTENT_STORAGE = 2; + + /** Permission for using the WebXR API. See: https://www.w3.org/TR/webxr */ + int PERMISSION_XR = 3; + + /** Permission for allowing autoplay of inaudible (silent) video. */ + int PERMISSION_AUTOPLAY_INAUDIBLE = 4; + + /** Permission for allowing autoplay of audible video. */ + int PERMISSION_AUTOPLAY_AUDIBLE = 5; + + /** Permission for accessing system media keys used to decode DRM media. */ + int PERMISSION_MEDIA_KEY_SYSTEM_ACCESS = 6; + + /** + * Permission for trackers to operate on the page -- disables all tracking protection features + * for a given site. + */ + int PERMISSION_TRACKING = 7; + + /** + * Permission for third party frames to access first party cookies and storage. May be granted + * heuristically in some cases. + */ + int PERMISSION_STORAGE_ACCESS = 8; + + /** + * Represents a content permission -- including the type of permission, the present value of the + * permission, the URL the permission pertains to, and other information. + */ + class ContentPermission { + @Retention(RetentionPolicy.SOURCE) + @IntDef({VALUE_PROMPT, VALUE_DENY, VALUE_ALLOW}) + public @interface Value {} + + /** The corresponding permission is currently set to default/prompt behavior. */ + public static final int VALUE_PROMPT = 3; + + /** The corresponding permission is currently set to deny. */ + public static final int VALUE_DENY = 2; + + /** The corresponding permission is currently set to allow. */ + public static final int VALUE_ALLOW = 1; + + /** The URI associated with this content permission. */ + public final @NonNull String uri; + + /** + * The third party origin associated with the request; currently only used for storage access + * permission. + */ + public final @Nullable String thirdPartyOrigin; + + /** + * A boolean indicating whether this content permission is associated with private browsing. + */ + public final boolean privateMode; + + /** The type of this permission; one of {@link #PERMISSION_GEOLOCATION PERMISSION_*}. */ + public final int permission; + + /** The value of the permission; one of {@link #VALUE_PROMPT VALUE_}. */ + public final @Value int value; + + /** + * The context ID associated with the permission if any. + * + * @see GeckoSessionSettings.Builder#contextId + */ + public final @Nullable String contextId; + + private final String mPrincipal; + + protected ContentPermission() { + this.uri = ""; + this.thirdPartyOrigin = null; + this.privateMode = false; + this.permission = PERMISSION_GEOLOCATION; + this.value = VALUE_ALLOW; + this.mPrincipal = ""; + this.contextId = null; + } + + private ContentPermission(final @NonNull GeckoBundle bundle) { + this.uri = bundle.getString("uri"); + this.mPrincipal = bundle.getString("principal"); + this.privateMode = bundle.getBoolean("privateMode"); + + final String permission = bundle.getString("perm"); + this.permission = convertType(permission); + if (permission.startsWith("3rdPartyStorage^")) { + // Storage access permissions are stored with the key "3rdPartyStorage^https://foo.com" + // where the third party origin is "https://foo.com". + this.thirdPartyOrigin = permission.substring(16); + } else { + this.thirdPartyOrigin = bundle.getString("thirdPartyOrigin"); + } + + this.value = bundle.getInt("value"); + this.contextId = + StorageController.retrieveUnsafeSessionContextId(bundle.getString("contextId")); + } + + /** + * Converts a JSONObject to a ContentPermission -- should only be used on the output of {@link + * #toJson()}. + * + * @param perm A JSONObject representing a ContentPermission, output by {@link #toJson()}. + * @return The corresponding ContentPermission. + */ + @AnyThread + public static @Nullable ContentPermission fromJson(final @NonNull JSONObject perm) { + ContentPermission res = null; + try { + res = new ContentPermission(GeckoBundle.fromJSONObject(perm)); + } catch (final JSONException e) { + Log.w(LOGTAG, "Failed to create ContentPermission; invalid JSONObject.", e); + } + return res; + } + + /** + * Converts a ContentPermission to a JSONObject that can be converted back to a + * ContentPermission by {@link #fromJson(JSONObject)}. + * + * @return A JSONObject representing this ContentPermission. Modifying any of the fields may + * result in undefined behavior when converted back to a ContentPermission and used. + * @throws JSONException if the conversion fails for any reason. + */ + @AnyThread + public @NonNull JSONObject toJson() throws JSONException { + return toGeckoBundle().toJSONObject(); + } + + private static int convertType(final @NonNull String type) { + if ("geolocation".equals(type)) { + return PERMISSION_GEOLOCATION; + } else if ("desktop-notification".equals(type)) { + return PERMISSION_DESKTOP_NOTIFICATION; + } else if ("persistent-storage".equals(type)) { + return PERMISSION_PERSISTENT_STORAGE; + } else if ("xr".equals(type)) { + return PERMISSION_XR; + } else if ("autoplay-media-inaudible".equals(type)) { + return PERMISSION_AUTOPLAY_INAUDIBLE; + } else if ("autoplay-media-audible".equals(type)) { + return PERMISSION_AUTOPLAY_AUDIBLE; + } else if ("media-key-system-access".equals(type)) { + return PERMISSION_MEDIA_KEY_SYSTEM_ACCESS; + } else if ("trackingprotection".equals(type) || "trackingprotection-pb".equals(type)) { + return PERMISSION_TRACKING; + } else if ("storage-access".equals(type) || type.startsWith("3rdPartyStorage^")) { + return PERMISSION_STORAGE_ACCESS; + } else { + return -1; + } + } + + // This also gets used in StorageController, so it's package rather than private. + /* package */ static String convertType(final int type, final boolean privateMode) { + switch (type) { + case PERMISSION_GEOLOCATION: + return "geolocation"; + case PERMISSION_DESKTOP_NOTIFICATION: + return "desktop-notification"; + case PERMISSION_PERSISTENT_STORAGE: + return "persistent-storage"; + case PERMISSION_XR: + return "xr"; + case PERMISSION_AUTOPLAY_INAUDIBLE: + return "autoplay-media-inaudible"; + case PERMISSION_AUTOPLAY_AUDIBLE: + return "autoplay-media-audible"; + case PERMISSION_MEDIA_KEY_SYSTEM_ACCESS: + return "media-key-system-access"; + case PERMISSION_TRACKING: + return privateMode ? "trackingprotection-pb" : "trackingprotection"; + case PERMISSION_STORAGE_ACCESS: + return "storage-access"; + default: + return ""; + } + } + + /* package */ static @NonNull ArrayList<ContentPermission> fromBundleArray( + final @NonNull GeckoBundle[] bundleArray) { + final ArrayList<ContentPermission> res = new ArrayList<ContentPermission>(); + if (bundleArray == null) { + return res; + } + + for (final GeckoBundle bundle : bundleArray) { + final ContentPermission temp = new ContentPermission(bundle); + if (temp.permission == -1 || temp.value < 1 || temp.value > 3) { + continue; + } + res.add(temp); + } + return res; + } + + /* package */ @NonNull + GeckoBundle toGeckoBundle() { + final GeckoBundle res = new GeckoBundle(7); + res.putString("uri", uri); + res.putString("thirdPartyOrigin", thirdPartyOrigin); + res.putString("principal", mPrincipal); + res.putBoolean("privateMode", privateMode); + res.putString("perm", convertType(permission, privateMode)); + res.putInt("value", value); + res.putString("contextId", contextId); + return res; + } + } + + /** Callback interface for notifying the result of a permission request. */ + interface Callback { + /** + * Called by the implementation after permissions are granted; the implementation must call + * either grant() or reject() for every request. + */ + @UiThread + default void grant() {} + + /** + * Called by the implementation when permissions are not granted; the implementation must call + * either grant() or reject() for every request. + */ + @UiThread + default void reject() {} + } + + /** + * Request Android app permissions. + * + * @param session GeckoSession instance requesting the permissions. + * @param permissions List of permissions to request; possible values are, + * android.Manifest.permission.ACCESS_COARSE_LOCATION + * android.Manifest.permission.ACCESS_FINE_LOCATION android.Manifest.permission.CAMERA + * android.Manifest.permission.RECORD_AUDIO + * @param callback Callback interface. + */ + @UiThread + default void onAndroidPermissionsRequest( + @NonNull final GeckoSession session, + @Nullable final String[] permissions, + @NonNull final Callback callback) { + callback.reject(); + } + + /** + * Request content permission. + * + * <p>Note, that in the case of PERMISSION_PERSISTENT_STORAGE, once permission has been granted + * for a site, it cannot be revoked. If the permission has previously been granted, it is the + * responsibility of the consuming app to remember the permission and prevent the prompt from + * being redisplayed to the user. + * + * @param session GeckoSession instance requesting the permission. + * @param perm An {@link ContentPermission} describing the permission being requested and its + * current status. + * @return A {@link GeckoResult} resolving to one of {@link ContentPermission#VALUE_PROMPT + * VALUE_*}, determining the response to the permission request and updating the permissions + * for this site. + */ + @UiThread + default @Nullable GeckoResult<Integer> onContentPermissionRequest( + @NonNull final GeckoSession session, @NonNull ContentPermission perm) { + return GeckoResult.fromValue(ContentPermission.VALUE_PROMPT); + } + + class MediaSource { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SOURCE_CAMERA, SOURCE_SCREEN, + SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, + SOURCE_OTHER + }) + public @interface Source {} + + /** Constant to indicate that camera will be recorded. */ + public static final int SOURCE_CAMERA = 0; + + /** Constant to indicate that screen will be recorded. */ + public static final int SOURCE_SCREEN = 1; + + /** Constant to indicate that microphone will be recorded. */ + public static final int SOURCE_MICROPHONE = 2; + + /** Constant to indicate that device audio playback will be recorded. */ + public static final int SOURCE_AUDIOCAPTURE = 3; + + /** Constant to indicate a media source that does not fall under the other categories. */ + public static final int SOURCE_OTHER = 4; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_VIDEO, TYPE_AUDIO}) + public @interface Type {} + + /** The media type is video. */ + public static final int TYPE_VIDEO = 0; + + /** The media type is audio. */ + public static final int TYPE_AUDIO = 1; + + /** A string giving a unique source identifier. */ + public final @NonNull String id; + + /** + * A string giving the name of the video source from the system (for example, "Camera 0, + * Facing back, Orientation 90"). May be empty. + */ + public final @Nullable String name; + + /** + * An int indicating the media source type. Possible values for a video source are: + * SOURCE_CAMERA, SOURCE_SCREEN, and SOURCE_OTHER. Possible values for an audio source are: + * SOURCE_MICROPHONE, SOURCE_AUDIOCAPTURE, and SOURCE_OTHER. + */ + public final @Source int source; + + /** An int giving the type of media, must be either TYPE_VIDEO or TYPE_AUDIO. */ + public final @Type int type; + + private static @Source int getSourceFromString(final String src) { + // The strings here should match those in MediaSourceEnum in MediaStreamTrack.webidl + if ("camera".equals(src)) { + return SOURCE_CAMERA; + } else if ("screen".equals(src) || "window".equals(src) || "browser".equals(src)) { + return SOURCE_SCREEN; + } else if ("microphone".equals(src)) { + return SOURCE_MICROPHONE; + } else if ("audioCapture".equals(src)) { + return SOURCE_AUDIOCAPTURE; + } else if ("other".equals(src) || "application".equals(src)) { + return SOURCE_OTHER; + } else { + throw new IllegalArgumentException( + "String: " + src + " is not a valid media source string"); + } + } + + private static @Type int getTypeFromString(final String type) { + // The strings here should match the possible types in MediaDevice::MediaDevice in + // MediaManager.cpp + if ("videoinput".equals(type)) { + return TYPE_VIDEO; + } else if ("audioinput".equals(type)) { + return TYPE_AUDIO; + } else { + throw new IllegalArgumentException( + "String: " + type + " is not a valid media type string"); + } + } + + /* package */ MediaSource(final GeckoBundle media) { + id = media.getString("id"); + name = media.getString("name"); + source = getSourceFromString(media.getString("mediaSource")); + type = getTypeFromString(media.getString("type")); + } + + /** Empty constructor for tests. */ + protected MediaSource() { + id = null; + name = null; + source = SOURCE_CAMERA; + type = TYPE_VIDEO; + } + } + + /** + * Callback interface for notifying the result of a media permission request, including which + * media source(s) to use. + */ + interface MediaCallback { + /** + * Called by the implementation after permissions are granted; the implementation must call + * one of grant() or reject() for every request. + * + * @param video "id" value from the bundle for the video source to use, or null when video is + * not requested. + * @param audio "id" value from the bundle for the audio source to use, or null when audio is + * not requested. + */ + @UiThread + default void grant(final @Nullable String video, final @Nullable String audio) {} + + /** + * Called by the implementation after permissions are granted; the implementation must call + * one of grant() or reject() for every request. + * + * @param video MediaSource for the video source to use (must be an original MediaSource + * object that was passed to the implementation); or null when video is not requested. + * @param audio MediaSource for the audio source to use (must be an original MediaSource + * object that was passed to the implementation); or null when audio is not requested. + */ + @UiThread + default void grant(final @Nullable MediaSource video, final @Nullable MediaSource audio) {} + + /** + * Called by the implementation when permissions are not granted; the implementation must call + * one of grant() or reject() for every request. + */ + @UiThread + default void reject() {} + } + + /** + * Request content media permissions, including request for which video and/or audio source to + * use. + * + * <p>Media permissions will still be requested if the associated device permissions have been + * denied if there are video or audio sources in that category that can still be accessed. It is + * the responsibility of consumers to ensure that media permission requests are not displayed in + * this case. + * + * @param session GeckoSession instance requesting the permission. + * @param uri The URI of the content requesting the permission. + * @param video List of video sources, or null if not requesting video. + * @param audio List of audio sources, or null if not requesting audio. + * @param callback Callback interface. + */ + @UiThread + default void onMediaPermissionRequest( + @NonNull final GeckoSession session, + @NonNull final String uri, + @Nullable final MediaSource[] video, + @Nullable final MediaSource[] audio, + @NonNull final MediaCallback callback) { + callback.reject(); + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + PermissionDelegate.PERMISSION_GEOLOCATION, + PermissionDelegate.PERMISSION_DESKTOP_NOTIFICATION, + PermissionDelegate.PERMISSION_PERSISTENT_STORAGE, + PermissionDelegate.PERMISSION_XR, + PermissionDelegate.PERMISSION_AUTOPLAY_INAUDIBLE, + PermissionDelegate.PERMISSION_AUTOPLAY_AUDIBLE, + PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS, + PermissionDelegate.PERMISSION_TRACKING, + PermissionDelegate.PERMISSION_STORAGE_ACCESS + }) + public @interface Permission {} + + /** + * Interface that SessionTextInput uses for performing operations such as opening and closing the + * software keyboard. If the delegate is not set, these operations are forwarded to the system + * {@link android.view.inputmethod.InputMethodManager} automatically. + */ + public interface TextInputDelegate { + /** Restarting input due to an input field gaining focus. */ + int RESTART_REASON_FOCUS = 0; + /** Restarting input due to an input field losing focus. */ + int RESTART_REASON_BLUR = 1; + /** + * Restarting input due to the content of the input field changing. For example, the input field + * type may have changed, or the current composition may have been committed outside of the + * input method. + */ + int RESTART_REASON_CONTENT_CHANGE = 2; + + /** + * Reset the input method, and discard any existing states such as the current composition or + * current autocompletion. Because the current focused editor may have changed, as part of the + * reset, a custom input method would normally call {@link + * SessionTextInput#onCreateInputConnection} to update its knowledge of the focused editor. Note + * that {@code restartInput} should be used to detect changes in focus, rather than {@link + * #showSoftInput} or {@link #hideSoftInput}, because focus changes are not always accompanied + * by requests to show or hide the soft input. This method is always called, even in viewless + * mode. + * + * @param session Session instance. + * @param reason Reason for the reset. + */ + @UiThread + default void restartInput( + @NonNull final GeckoSession session, @RestartReason final int reason) {} + + /** + * Display the soft input. May be called consecutively, even if the soft input is already shown. + * This method is always called, even in viewless mode. + * + * @param session Session instance. + * @see #hideSoftInput + */ + @UiThread + default void showSoftInput(@NonNull final GeckoSession session) {} + + /** + * Hide the soft input. May be called consecutively, even if the soft input is already hidden. + * This method is always called, even in viewless mode. + * + * @param session Session instance. + * @see #showSoftInput + */ + @UiThread + default void hideSoftInput(@NonNull final GeckoSession session) {} + + /** + * Update the soft input on the current selection. This method is <i>not</i> called in viewless + * mode. + * + * @param session Session instance. + * @param selStart Start offset of the selection. + * @param selEnd End offset of the selection. + * @param compositionStart Composition start offset, or -1 if there is no composition. + * @param compositionEnd Composition end offset, or -1 if there is no composition. + */ + @UiThread + default void updateSelection( + @NonNull final GeckoSession session, + final int selStart, + final int selEnd, + final int compositionStart, + final int compositionEnd) {} + + /** + * Update the soft input on the current extracted text, as requested through {@link + * android.view.inputmethod.InputConnection#getExtractedText}. Consequently, this method is + * <i>not</i> called in viewless mode. + * + * @param session Session instance. + * @param request The extract text request. + * @param text The extracted text. + */ + @UiThread + default void updateExtractedText( + @NonNull final GeckoSession session, + @NonNull final ExtractedTextRequest request, + @NonNull final ExtractedText text) {} + + /** + * Update the cursor-anchor information as requested through {@link + * android.view.inputmethod.InputConnection#requestCursorUpdates}. Consequently, this method is + * <i>not</i> called in viewless mode. + * + * @param session Session instance. + * @param info Cursor-anchor information. + */ + @UiThread + default void updateCursorAnchorInfo( + @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + TextInputDelegate.RESTART_REASON_FOCUS, + TextInputDelegate.RESTART_REASON_BLUR, + TextInputDelegate.RESTART_REASON_CONTENT_CHANGE + }) + public @interface RestartReason {} + + /* package */ void onSurfaceChanged(final @NonNull SurfaceInfo surfaceInfo) { + ThreadUtils.assertOnUiThread(); + + mWidth = surfaceInfo.mWidth; + mHeight = surfaceInfo.mHeight; + + if (mCompositorReady) { + mCompositor.syncResumeResizeCompositor( + surfaceInfo.mLeft, + surfaceInfo.mTop, + surfaceInfo.mWidth, + surfaceInfo.mHeight, + surfaceInfo.mSurface, + surfaceInfo.mSurfaceControl); + onWindowBoundsChanged(); + return; + } + + // We have a valid surface but we're not attached or the compositor + // is not ready; save the surface for later when we're ready. + mSurfaceInfo = surfaceInfo; + + // Adjust bounds as the last step. + onWindowBoundsChanged(); + } + + /* package */ void onSurfaceDestroyed() { + ThreadUtils.assertOnUiThread(); + + if (mCompositorReady) { + mCompositor.syncPauseCompositor(); + return; + } + + // While the surface was valid, we never became attached or the + // compositor never became ready; clear the saved surface. + mSurfaceInfo = null; + } + + /* package */ void onScreenOriginChanged(final int left, final int top) { + ThreadUtils.assertOnUiThread(); + + if (mLeft == left && mTop == top) { + return; + } + + mLeft = left; + mTop = top; + onWindowBoundsChanged(); + } + + /* package */ void setDynamicToolbarMaxHeight(final int height) { + if (mDynamicToolbarMaxHeight == height) { + return; + } + + if (mHeight != 0 && height != 0 && mHeight < height) { + Log.w( + LOGTAG, + new AssertionError( + "The maximum height of the dynamic toolbar (" + + height + + ") should be smaller than GeckoView height (" + + mHeight + + ")")); + } + + mDynamicToolbarMaxHeight = height; + + if (mAttachedCompositor) { + mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + } + } + + /* package */ void setFixedBottomOffset(final int offset) { + if (mFixedBottomOffset == offset) { + return; + } + + mFixedBottomOffset = offset; + + if (mCompositorReady) { + mCompositor.setFixedBottomOffset(mFixedBottomOffset); + } + } + + /* package */ void onCompositorAttached() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mAttachedCompositor = true; + mCompositor.attachNPZC(mPanZoomController.mNative); + + if (mSurfaceInfo != null) { + // If we have a valid surface, create the compositor now that we're attached. + // Leave mSurface alone because we'll need it later for onCompositorReady. + onSurfaceChanged(mSurfaceInfo); + } + + mCompositor.sendToolbarAnimatorMessage(IS_COMPOSITOR_CONTROLLER_OPEN); + mCompositor.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + } + + /* package */ void onCompositorDetached() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mController != null) { + mController.onCompositorDetached(); + } + + mAttachedCompositor = false; + mCompositorReady = false; + } + + /* package */ void handleCompositorMessage(final int message) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + switch (message) { + case COMPOSITOR_CONTROLLER_OPEN: + { + if (isCompositorReady()) { + return; + } + + // Delay calling onCompositorReady to avoid deadlock due + // to synchronous call to the compositor. + ThreadUtils.postToUiThread(this::onCompositorReady); + break; + } + + case FIRST_PAINT: + { + if (mController != null) { + mController.onFirstPaint(); + } + final ContentDelegate delegate = mContentHandler.getDelegate(); + if (delegate != null) { + delegate.onFirstComposite(this); + } + break; + } + + case LAYERS_UPDATED: + { + if (mController != null) { + mController.notifyDrawCallbacks(); + } + break; + } + + default: + { + Log.w(LOGTAG, "Unexpected message: " + message); + break; + } + } + } + + /* package */ boolean isCompositorReady() { + return mCompositorReady; + } + + /* package */ void onCompositorReady() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (!mAttachedCompositor) { + return; + } + + mCompositorReady = true; + + if (mController != null) { + mController.onCompositorReady(); + } + + if (mSurfaceInfo != null) { + // If we have a valid surface, resume the + // compositor now that the compositor is ready. + onSurfaceChanged(mSurfaceInfo); + mSurfaceInfo = null; + } + + if (mFixedBottomOffset != 0) { + mCompositor.setFixedBottomOffset(mFixedBottomOffset); + } + } + + /* package */ void updateOverscrollVelocity(final float x, final float y) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mOverscroll == null) { + return; + } + + // Multiply the velocity by 1000 to match what was done in JPZ. + mOverscroll.setVelocity(x * 1000.0f, OverscrollEdgeEffect.AXIS_X); + mOverscroll.setVelocity(y * 1000.0f, OverscrollEdgeEffect.AXIS_Y); + } + + /* package */ void updateOverscrollOffset(final float x, final float y) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mOverscroll == null) { + return; + } + + mOverscroll.setDistance(x, OverscrollEdgeEffect.AXIS_X); + mOverscroll.setDistance(y, OverscrollEdgeEffect.AXIS_Y); + } + + /* package */ void onMetricsChanged(final float scrollX, final float scrollY, final float zoom) { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + mViewportLeft = scrollX; + mViewportTop = scrollY; + mViewportZoom = zoom; + } + + /* protected */ void onWindowBoundsChanged() { + if (DEBUG) { + ThreadUtils.assertOnUiThread(); + } + + if (mHeight != 0 && mDynamicToolbarMaxHeight != 0 && mHeight < mDynamicToolbarMaxHeight) { + Log.w( + LOGTAG, + new AssertionError( + "The maximum height of the dynamic toolbar (" + + mDynamicToolbarMaxHeight + + ") should be smaller than GeckoView height (" + + mHeight + + ")")); + } + + final int toolbarHeight = 0; + + mClientTop = mTop + toolbarHeight; + // If the view is not tall enough to even fix the toolbar we just + // default the client height to 0 + mClientHeight = Math.max(mHeight - toolbarHeight, 0); + + if (mAttachedCompositor) { + mCompositor.onBoundsChanged(mLeft, mClientTop, mWidth, mClientHeight); + } + + if (mOverscroll != null) { + mOverscroll.setSize(mWidth, mClientHeight); + } + } + + /* pacakge */ void onSafeAreaInsetsChanged( + final int top, final int right, final int bottom, final int left) { + ThreadUtils.assertOnUiThread(); + + if (mAttachedCompositor) { + mCompositor.onSafeAreaInsetsChanged(top, right, bottom, left); + } + } + + /* package */ void setPointerIcon( + final int defaultCursor, final @Nullable Bitmap customCursor, final float x, final float y) { + ThreadUtils.assertOnUiThread(); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return; + } + + final PointerIcon icon; + if (customCursor != null) { + try { + icon = PointerIcon.create(customCursor, x, y); + } catch (final IllegalArgumentException e) { + // x/y hotspot might be invalid + return; + } + } else { + final Context context = GeckoAppShell.getApplicationContext(); + icon = PointerIcon.getSystemIcon(context, defaultCursor); + } + + final ContentDelegate delegate = getContentDelegate(); + if (delegate != null) { + delegate.onPointerIconChange(this, icon); + } + } + + /** GeckoSession applications implement this interface to handle media events. */ + public interface MediaDelegate { + + class RecordingDevice { + + /* + * Default status flags for this RecordingDevice. + */ + public static class Status { + public static final long RECORDING = 0; + public static final long INACTIVE = 1 << 0; + + // Do not instantiate this class. + protected Status() {} + } + + /* + * Default device types for this RecordingDevice. + */ + public static class Type { + public static final long CAMERA = 0; + public static final long MICROPHONE = 1 << 0; + + // Do not instantiate this class. + protected Type() {} + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Status.RECORDING, Status.INACTIVE}) + public @interface RecordingStatus {} + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Type.CAMERA, Type.MICROPHONE}) + public @interface DeviceType {} + + /** + * A long giving the current recording status, must be either Status.RECORDING, Status.PAUSED + * or Status.INACTIVE. + */ + public final @RecordingStatus long status; + + /** + * A long giving the type of the recording device, must be either Type.CAMERA or + * Type.MICROPHONE. + */ + public final @DeviceType long type; + + private static @DeviceType long getTypeFromString(final String type) { + if ("microphone".equals(type)) { + return Type.MICROPHONE; + } else if ("camera".equals(type)) { + return Type.CAMERA; + } else { + throw new IllegalArgumentException( + "String: " + type + " is not a valid recording device string"); + } + } + + private static @RecordingStatus long getStatusFromString(final String type) { + if ("recording".equals(type)) { + return Status.RECORDING; + } else { + return Status.INACTIVE; + } + } + + /* package */ RecordingDevice(final GeckoBundle media) { + status = getStatusFromString(media.getString("status")); + type = getTypeFromString(media.getString("type")); + } + + /** Empty constructor for tests. */ + protected RecordingDevice() { + status = Status.INACTIVE; + type = Type.CAMERA; + } + } + + /** + * A recording device has changed state. Any change to the recording state of the devices + * microphone or camera will call this delegate method. The argument provides details of the + * active recording devices. + * + * @param session The session that the event has originated from. + * @param devices The list of active devices and their recording state. + */ + @UiThread + default void onRecordingStatusChanged( + @NonNull final GeckoSession session, @NonNull final RecordingDevice[] devices) {} + } + + /** An interface for recording new history visits and fetching the visited status for links. */ + public interface HistoryDelegate { + /** A representation of an entry in browser history. */ + public interface HistoryItem { + /** + * Get the URI of this history element. + * + * @return A String representing the URI of this history element. + */ + @AnyThread + default @NonNull String getUri() { + throw new UnsupportedOperationException("HistoryItem.getUri() called on invalid object."); + } + + /** + * Get the title of this history element. + * + * @return A String representing the title of this history element. + */ + @AnyThread + default @NonNull String getTitle() { + throw new UnsupportedOperationException( + "HistoryItem.getString() called on invalid object."); + } + } + + /** + * A representation of browser history, accessible as a `List`. The list itself and its entries + * are immutable; any attempt to mutate will result in an `UnsupportedOperationException`. + */ + public interface HistoryList extends List<HistoryItem> { + /** + * Get the current index in browser history. + * + * @return An int representing the current index in browser history. + */ + @AnyThread + default int getCurrentIndex() { + throw new UnsupportedOperationException( + "HistoryList.getCurrentIndex() called on invalid object."); + } + } + + // These flags are similar to those in `IHistory::LoadFlags`, but we use + // different values to decouple GeckoView from Gecko changes. These + // should be kept in sync with `GeckoViewHistory::GeckoViewVisitFlags`. + + /** The URL was visited a top-level window. */ + final int VISIT_TOP_LEVEL = 1 << 0; + /** The URL is the target of a temporary redirect. */ + final int VISIT_REDIRECT_TEMPORARY = 1 << 1; + /** The URL is the target of a permanent redirect. */ + final int VISIT_REDIRECT_PERMANENT = 1 << 2; + /** The URL is temporarily redirected to another URL. */ + final int VISIT_REDIRECT_SOURCE = 1 << 3; + /** The URL is permanently redirected to another URL. */ + final int VISIT_REDIRECT_SOURCE_PERMANENT = 1 << 4; + /** The URL failed to load due to a client or server error. */ + final int VISIT_UNRECOVERABLE_ERROR = 1 << 5; + + /** + * Records a visit to a page. + * + * @param session The session where the URL was visited. + * @param url The visited URL. + * @param lastVisitedURL The last visited URL in this session, to detect redirects and reloads. + * @param flags Additional flags for this visit, including redirect and error statuses. This is + * a bitmask of one or more {@link #VISIT_TOP_LEVEL VISIT_*} flags, OR-ed together. + * @return A {@link GeckoResult} completed with a boolean indicating whether to highlight links + * for the new URL as visited ({@code true}) or unvisited ({@code false}). + */ + @UiThread + default @Nullable GeckoResult<Boolean> onVisited( + @NonNull final GeckoSession session, + @NonNull final String url, + @Nullable final String lastVisitedURL, + @VisitFlags final int flags) { + return null; + } + + /** + * Returns the visited statuses for links on a page. This is used to highlight links as visited + * or unvisited, for example. + * + * @param session The session requesting the visited statuses. + * @param urls A list of URLs to check. + * @return A {@link GeckoResult} completed with a list of booleans corresponding to the URLs in + * {@code urls}, and indicating whether to highlight links for each URL as visited ({@code + * true}) or unvisited ({@code false}). + */ + @UiThread + default @Nullable GeckoResult<boolean[]> getVisited( + @NonNull final GeckoSession session, @NonNull final String[] urls) { + return null; + } + + @UiThread + @SuppressWarnings("checkstyle:javadocmethod") + default void onHistoryStateChange( + @NonNull final GeckoSession session, @NonNull final HistoryList historyList) {} + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + HistoryDelegate.VISIT_TOP_LEVEL, + HistoryDelegate.VISIT_REDIRECT_TEMPORARY, + HistoryDelegate.VISIT_REDIRECT_PERMANENT, + HistoryDelegate.VISIT_REDIRECT_SOURCE, + HistoryDelegate.VISIT_REDIRECT_SOURCE_PERMANENT, + HistoryDelegate.VISIT_UNRECOVERABLE_ERROR + }) + public @interface VisitFlags {} + + private Autofill.Support getAutofillSupport() { + return mAutofillSupport; + } + + /** + * Sets the autofill delegate for this session. + * + * @param delegate An instance of {@link Autofill.Delegate}. + */ + @UiThread + public void setAutofillDelegate(final @Nullable Autofill.Delegate delegate) { + getAutofillSupport().setDelegate(delegate); + } + + /** + * @return The current {@link Autofill.Delegate} for this session, if any. + */ + @UiThread + public @Nullable Autofill.Delegate getAutofillDelegate() { + return getAutofillSupport().getDelegate(); + } + + /** + * Provides an autofill structure similar to {@link + * View#onProvideAutofillVirtualStructure(ViewStructure, int)} , but does not rely on {@link + * ViewStructure} to build the tree. This is useful for apps that want to provide autofill + * functionality without using the Android autofill system or requiring API 26. + * + * @return The elements available for autofill. + */ + @UiThread + public @NonNull Autofill.Session getAutofillSession() { + return getAutofillSupport().getAutofillSession(); + } + + /** + * Saves a PDF of the currently displayed page. + * + * @return A GeckoResult with an InputStream containing the PDF. The result could + * CompleteExceptionally with a {@link GeckoPrintException}s, if there are any issues while + * generating the PDF. + */ + @AnyThread + public @NonNull GeckoResult<InputStream> saveAsPdf() { + final GeckoResult<InputStream> geckoResult = new GeckoResult<>(); + this.mWindow.printToPdf(geckoResult); + return geckoResult; + } + + private static String rgbaToArgb(final String color) { + // We expect #rrggbbaa + if (color.length() != 9 || !color.startsWith("#")) { + throw new IllegalArgumentException("Invalid color format"); + } + + return "#" + color.substring(7) + color.substring(1, 7); + } + + private static void fixupManifestColor(final JSONObject manifest, final String name) + throws JSONException { + if (manifest.isNull(name)) { + return; + } + + manifest.put(name, rgbaToArgb(manifest.getString(name))); + } + + private static JSONObject fixupWebAppManifest(final JSONObject manifest) { + // Colors are #rrggbbaa, but we want them to be #aarrggbb, since that's what + // android.graphics.Color expects. + try { + fixupManifestColor(manifest, "theme_color"); + fixupManifestColor(manifest, "background_color"); + } catch (final JSONException e) { + Log.w(LOGTAG, "Failed to fixup web app manifest", e); + } + + return manifest; + } + + private static boolean maybeCheckDataUriLength(final @NonNull Loader request) { + if (!request.mIsDataUri) { + return true; + } + + return request.mUri.length() <= DATA_URI_MAX_LENGTH; + } + + /** Thrown when failure occurs when printing from a website. */ + @WrapForJNI + public static class GeckoPrintException extends Exception { + /** The print service was not available. */ + public static final int ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE = -1; + /** The print service was not created due to an initialization error. */ + public static final int ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS = -2; + /** An error happened while trying to find the canonical browing context */ + public static final int ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT = -3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE, + ERROR_UNABLE_TO_CREATE_PRINT_SETTINGS, + ERROR_UNABLE_TO_RETRIEVE_CANONICAL_BROWSING_CONTEXT, + }) + public @interface Codes {} + + /** One of {@link Codes} that provides more information about this exception. */ + public final @Codes int code; + + @Override + public String toString() { + return "GeckoPrintException: " + code; + } + + /* package */ GeckoPrintException(final @Codes int code) { + this.code = code; + } + + /** For testing. */ + protected GeckoPrintException() { + code = ERROR_PRINT_SETTINGS_SERVICE_NOT_AVAILABLE; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java new file mode 100644 index 0000000000..629211a4a6 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionHandler.java @@ -0,0 +1,106 @@ +/* -*- 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.geckoview; + +import android.util.Log; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/* package */ abstract class GeckoSessionHandler<Delegate> implements BundleEventListener { + + private static final String LOGTAG = "GeckoSessionHandler"; + private static final boolean DEBUG = false; + + private final String mModuleName; + private final String[] mEvents; + private Delegate mDelegate; + private boolean mRegisteredListeners; + + /* package */ GeckoSessionHandler( + final String module, final GeckoSession session, final String[] events) { + this(module, session, events, new String[] {}); + } + + /* package */ GeckoSessionHandler( + final String module, + final GeckoSession session, + final String[] events, + final String[] defaultEvents) { + session.handlersCount++; + + mModuleName = module; + mEvents = events; + + // Default events are always active + session.getEventDispatcher().registerUiThreadListener(this, defaultEvents); + } + + public Delegate getDelegate() { + return mDelegate; + } + + public void setDelegate(final Delegate delegate, final GeckoSession session) { + if (mDelegate == delegate) { + return; + } + + mDelegate = delegate; + + if (!mRegisteredListeners && delegate != null) { + session.getEventDispatcher().registerUiThreadListener(this, mEvents); + mRegisteredListeners = true; + } + + // If session is not open, we will update module state during session opening. + if (!session.isOpen()) { + return; + } + + final GeckoBundle msg = new GeckoBundle(2); + msg.putString("module", mModuleName); + msg.putBoolean("enabled", isEnabled()); + session.getEventDispatcher().dispatch("GeckoView:UpdateModuleState", msg); + } + + public String getName() { + return mModuleName; + } + + public boolean isEnabled() { + return mDelegate != null; + } + + @Override + @UiThread + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, mModuleName + " handleMessage: event = " + event); + } + + if (mDelegate != null) { + handleMessage(mDelegate, event, message, callback); + } else { + handleDefaultMessage(event, message, callback); + } + } + + protected abstract void handleMessage( + final Delegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback); + + protected void handleDefaultMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (callback != null) { + callback.sendError("No delegate registered"); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java new file mode 100644 index 0000000000..886b2c4b5a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoSessionSettings.java @@ -0,0 +1,690 @@ +/* -*- 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.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Arrays; +import java.util.Collection; +import org.mozilla.gecko.util.GeckoBundle; + +@AnyThread +public final class GeckoSessionSettings implements Parcelable { + + /** Settings builder used to construct the settings object. */ + @AnyThread + public static final class Builder { + private final GeckoSessionSettings mSettings; + + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mSettings = new GeckoSessionSettings(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public Builder(final GeckoSessionSettings settings) { + mSettings = new GeckoSessionSettings(settings); + } + + /** + * Finalize and return the settings. + * + * @return The constructed settings. + */ + public @NonNull GeckoSessionSettings build() { + return new GeckoSessionSettings(mSettings); + } + + /** + * Set the chrome URI. + * + * @param uri The URI to set the Chrome URI to. + * @return This Builder instance. + */ + public @NonNull Builder chromeUri(final @NonNull String uri) { + mSettings.setChromeUri(uri); + return this; + } + + /** + * Set the screen id. + * + * @param id The screen id. + * @return This Builder instance. + */ + public @NonNull Builder screenId(final int id) { + mSettings.setScreenId(id); + return this; + } + + /** + * Set the privacy mode for this instance. + * + * @param flag A flag determining whether Private Mode should be enabled. Default is false. + * @return This Builder instance. + */ + public @NonNull Builder usePrivateMode(final boolean flag) { + mSettings.setUsePrivateMode(flag); + return this; + } + + /** + * Set the session context ID for this instance. Setting a context ID partitions the cookie jars + * based on the provided IDs. This isolates the browser storage like cookies and localStorage + * between sessions, only sessions that share the same ID share storage data. + * + * <p>Warning: Storage data is collected persistently for each context, to delete context data, + * call {@link StorageController#clearDataForSessionContext} for the given context. + * + * @param value The custom context ID. The default ID is null, which removes isolation for this + * instance. + * @return This Builder instance. + */ + public @NonNull Builder contextId(final @Nullable String value) { + mSettings.setContextId(value); + return this; + } + + /** + * Set whether tracking protection should be enabled. + * + * @param flag A flag determining whether tracking protection should be enabled. Default is + * false. + * @return This Builder instance. + */ + public @NonNull Builder useTrackingProtection(final boolean flag) { + mSettings.setUseTrackingProtection(flag); + return this; + } + + /** + * Set the user agent mode. + * + * @param mode The mode to set the user agent to. Use one or more of the {@link + * GeckoSessionSettings#USER_AGENT_MODE_MOBILE GeckoSessionSettings.USER_AGENT_MODE_*} + * flags. + * @return This Builder instance. + */ + public @NonNull Builder userAgentMode(final int mode) { + mSettings.setUserAgentMode(mode); + return this; + } + + /** + * Override the user agent. + * + * @param agent The user agent to use. + * @return This Builder instance. + */ + public @NonNull Builder userAgentOverride(final @NonNull String agent) { + mSettings.setUserAgentOverride(agent); + return this; + } + + /** + * Specify which display-mode to use. + * + * @param mode The mode to set the display to. Use one or more of the {@link + * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags. + * @return This Builder instance. + */ + public @NonNull Builder displayMode(final int mode) { + mSettings.setDisplayMode(mode); + return this; + } + + /** + * Set whether to suspend the playing of media when the session is inactive. + * + * @param flag A flag determining whether media should be suspended. Default is false. + * @return This Builder instance. + */ + public @NonNull Builder suspendMediaWhenInactive(final boolean flag) { + mSettings.setSuspendMediaWhenInactive(flag); + return this; + } + + /** + * Set whether JavaScript support should be enabled. + * + * @param flag A flag determining whether JavaScript should be enabled. Default is true. + * @return This Builder instance. + */ + public @NonNull Builder allowJavascript(final boolean flag) { + mSettings.setAllowJavascript(flag); + return this; + } + + /** + * Set whether the entire accessible tree should be exposed with no caching. + * + * @param flag A flag determining if the entire accessible tree should be exposed. Default is + * false. + * @return This Builder instance. + */ + public @NonNull Builder fullAccessibilityTree(final boolean flag) { + mSettings.setFullAccessibilityTree(flag); + return this; + } + + /** + * Specify which viewport mode to use. + * + * @param mode The mode to set the viewport to. Use one or more of the {@link + * GeckoSessionSettings#VIEWPORT_MODE_MOBILE GeckoSessionSettings.VIEWPORT_MODE_*} flags. + * @return This Builder instance. + */ + public @NonNull Builder viewportMode(final int mode) { + mSettings.setViewportMode(mode); + return this; + } + } + + private static final String LOGTAG = "GeckoSessionSettings"; + private static final boolean DEBUG = false; + + // This needs to match GeckoViewSettings.jsm + public static final int DISPLAY_MODE_BROWSER = 0; + public static final int DISPLAY_MODE_MINIMAL_UI = 1; + public static final int DISPLAY_MODE_STANDALONE = 2; + public static final int DISPLAY_MODE_FULLSCREEN = 3; + + // This needs to match GeckoViewSettingsChild.js and GeckoViewSettings.jsm + public static final int USER_AGENT_MODE_MOBILE = 0; + public static final int USER_AGENT_MODE_DESKTOP = 1; + public static final int USER_AGENT_MODE_VR = 2; + + // This needs to match GeckoViewSettingsChild.js + /** + * Mobile-friendly pages will be rendered using a viewport based on their <meta> viewport + * tag. All other pages will be rendered using a special desktop mode viewport, which has a width + * of 980 CSS px. + */ + public static final int VIEWPORT_MODE_MOBILE = 0; + + /** + * All pages will be rendered using the special desktop mode viewport, which has a width of 980 + * CSS px, regardless of whether the page has a <meta> viewport tag specified or not. + */ + public static final int VIEWPORT_MODE_DESKTOP = 1; + + public static class Key<T> { + /* package */ final String name; + /* package */ final boolean initOnly; + /* package */ final Collection<T> values; + + /* package */ Key(final String name) { + this(name, /* initOnly */ false, /* values */ null); + } + + /* package */ Key(final String name, final boolean initOnly, final Collection<T> values) { + this.name = name; + this.initOnly = initOnly; + this.values = values; + } + } + + /** + * Key to set the chrome window URI, or null to use default URI. Read-only once session is open. + */ + private static final Key<String> CHROME_URI = + new Key<String>("chromeUri", /* initOnly */ true, /* values */ null); + /** Key to set the window screen ID, or 0 to use default ID. Read-only once session is open. */ + private static final Key<Integer> SCREEN_ID = + new Key<Integer>("screenId", /* initOnly */ true, /* values */ null); + + /** Key to enable and disable tracking protection. */ + private static final Key<Boolean> USE_TRACKING_PROTECTION = + new Key<Boolean>("useTrackingProtection"); + /** Key to enable and disable private mode browsing. Read-only once session is open. */ + private static final Key<Boolean> USE_PRIVATE_MODE = + new Key<Boolean>("usePrivateMode", /* initOnly */ true, /* values */ null); + + /** Key to specify which user agent mode we should use. */ + private static final Key<Integer> USER_AGENT_MODE = + new Key<Integer>( + "userAgentMode", /* initOnly */ + false, + Arrays.asList(USER_AGENT_MODE_MOBILE, USER_AGENT_MODE_DESKTOP, USER_AGENT_MODE_VR)); + + /** + * Key to specify the user agent override string. Set value to null to use the user agent + * specified by USER_AGENT_MODE. + */ + private static final Key<String> USER_AGENT_OVERRIDE = + new Key<String>("userAgentOverride", /* initOnly */ false, /* values */ null); + + /** Key to specify which viewport mode we should use. */ + private static final Key<Integer> VIEWPORT_MODE = + new Key<Integer>( + "viewportMode", /* initOnly */ + false, + Arrays.asList(VIEWPORT_MODE_MOBILE, VIEWPORT_MODE_DESKTOP)); + + /** Key to specify which display-mode we should use. */ + private static final Key<Integer> DISPLAY_MODE = + new Key<Integer>( + "displayMode", /* initOnly */ + false, + Arrays.asList( + DISPLAY_MODE_BROWSER, DISPLAY_MODE_MINIMAL_UI, + DISPLAY_MODE_STANDALONE, DISPLAY_MODE_FULLSCREEN)); + + /** Key to specify if media should be suspended when the session is inactive. */ + private static final Key<Boolean> SUSPEND_MEDIA_WHEN_INACTIVE = + new Key<Boolean>("suspendMediaWhenInactive", /* initOnly */ false, /* values */ null); + + /** Key to specify if JavaScript should be allowed on this session. */ + private static final Key<Boolean> ALLOW_JAVASCRIPT = + new Key<Boolean>("allowJavascript", /* initOnly */ false, /* values */ null); + /** Key to specify if entire accessible tree should be exposed with no caching. */ + private static final Key<Boolean> FULL_ACCESSIBILITY_TREE = + new Key<Boolean>("fullAccessibilityTree", /* initOnly */ false, /* values */ null); + + /** + * Key to specify if this GeckoSession is a Popup or not. Popup sessions can paint over other + * sessions and are not exposed to the tabs WebExtension API. + */ + private static final Key<Boolean> IS_POPUP = + new Key<Boolean>("isPopup", /* initOnly */ false, /* values */ null); + + /** Internal Gecko key to specify the session context ID. Derived from `UNSAFE_CONTEXT_ID`. */ + private static final Key<String> CONTEXT_ID = + new Key<String>("sessionContextId", /* initOnly */ true, /* values */ null); + + /** User-provided key to specify the session context ID. */ + private static final Key<String> UNSAFE_CONTEXT_ID = + new Key<String>("unsafeSessionContextId", /* initOnly */ true, /* values */ null); + + private final GeckoSession mSession; + private final GeckoBundle mBundle; + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSessionSettings() { + this(null, null); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoSessionSettings(final @NonNull GeckoSessionSettings settings) { + this(settings, null); + } + + /* package */ GeckoSessionSettings( + final @Nullable GeckoSessionSettings settings, final @Nullable GeckoSession session) { + mSession = session; + + if (settings != null) { + mBundle = new GeckoBundle(settings.mBundle); + return; + } + + mBundle = new GeckoBundle(); + mBundle.putString(CHROME_URI.name, null); + mBundle.putInt(SCREEN_ID.name, 0); + mBundle.putBoolean(USE_TRACKING_PROTECTION.name, false); + mBundle.putBoolean(USE_PRIVATE_MODE.name, false); + mBundle.putBoolean(SUSPEND_MEDIA_WHEN_INACTIVE.name, false); + mBundle.putBoolean(ALLOW_JAVASCRIPT.name, true); + mBundle.putBoolean(FULL_ACCESSIBILITY_TREE.name, false); + mBundle.putBoolean(IS_POPUP.name, false); + mBundle.putInt(USER_AGENT_MODE.name, USER_AGENT_MODE_MOBILE); + mBundle.putString(USER_AGENT_OVERRIDE.name, null); + mBundle.putInt(VIEWPORT_MODE.name, VIEWPORT_MODE_MOBILE); + mBundle.putInt(DISPLAY_MODE.name, DISPLAY_MODE_BROWSER); + mBundle.putString(CONTEXT_ID.name, null); + mBundle.putString(UNSAFE_CONTEXT_ID.name, null); + } + + /** + * Set whether tracking protection should be enabled. + * + * @param value A flag determining whether tracking protection should be enabled. Default is + * false. + */ + public void setUseTrackingProtection(final boolean value) { + setBoolean(USE_TRACKING_PROTECTION, value); + } + + /** + * Set the privacy mode for this instance. + * + * @param value A flag determining whether Private Mode should be enabled. Default is false. + */ + private void setUsePrivateMode(final boolean value) { + setBoolean(USE_PRIVATE_MODE, value); + } + + /** + * Set whether to suspend the playing of media when the session is inactive. + * + * @param value A flag determining whether media should be suspended. Default is false. + */ + public void setSuspendMediaWhenInactive(final boolean value) { + setBoolean(SUSPEND_MEDIA_WHEN_INACTIVE, value); + } + + /** + * Set whether JavaScript support should be enabled. + * + * @param value A flag determining whether JavaScript should be enabled. Default is true. + */ + public void setAllowJavascript(final boolean value) { + setBoolean(ALLOW_JAVASCRIPT, value); + } + + /** + * Set whether the entire accessible tree should be exposed with no caching. + * + * @param value A flag determining full accessibility tree should be exposed. Default is false. + */ + public void setFullAccessibilityTree(final boolean value) { + setBoolean(FULL_ACCESSIBILITY_TREE, value); + } + + /* package */ void setIsPopup(final boolean value) { + setBoolean(IS_POPUP, value); + } + + private void setBoolean(final Key<Boolean> key, final boolean value) { + synchronized (mBundle) { + if (valueChangedLocked(key, value)) { + mBundle.putBoolean(key.name, value); + dispatchUpdate(); + } + } + } + + /** + * Whether tracking protection is enabled. + * + * @return true if tracking protection is enabled, false if not. + */ + public boolean getUseTrackingProtection() { + return getBoolean(USE_TRACKING_PROTECTION); + } + + /** + * Whether private mode is enabled. + * + * @return true if private mode is enabled, false if not. + */ + public boolean getUsePrivateMode() { + return getBoolean(USE_PRIVATE_MODE); + } + + /** + * The context ID for this session. + * + * @return The context ID for this session. + */ + public @Nullable String getContextId() { + // Return the user-provided unsafe string. + return getString(UNSAFE_CONTEXT_ID); + } + + /** + * Whether media will be suspended when the session is inactice. + * + * @return true if media will be suspended, false if not. + */ + public boolean getSuspendMediaWhenInactive() { + return getBoolean(SUSPEND_MEDIA_WHEN_INACTIVE); + } + + /** + * Whether javascript execution is allowed. + * + * @return true if javascript execution is allowed, false if not. + */ + public boolean getAllowJavascript() { + return getBoolean(ALLOW_JAVASCRIPT); + } + + /** + * Whether entire accessible tree is exposed with no caching. + * + * @return true if accessibility tree is exposed, false if not. + */ + public boolean getFullAccessibilityTree() { + return getBoolean(FULL_ACCESSIBILITY_TREE); + } + + /* package */ boolean getIsPopup() { + return getBoolean(IS_POPUP); + } + + private boolean getBoolean(final Key<Boolean> key) { + synchronized (mBundle) { + return mBundle.getBoolean(key.name); + } + } + + /** + * Set the screen id. + * + * @param value The screen id. + */ + private void setScreenId(final int value) { + setInt(SCREEN_ID, value); + } + + /** + * Specify which user agent mode we should use + * + * @param value One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE + * GeckoSessionSettings.USER_AGENT_MODE_*} flags. + */ + public void setUserAgentMode(final int value) { + setInt(USER_AGENT_MODE, value); + } + + /** + * Set the display mode. + * + * @param value The mode to set the display to. Use one or more of the {@link + * GeckoSessionSettings#DISPLAY_MODE_BROWSER GeckoSessionSettings.DISPLAY_MODE_*} flags. + */ + public void setDisplayMode(final int value) { + setInt(DISPLAY_MODE, value); + } + + /** + * Specify which viewport mode we should use + * + * @param value One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE_MOBILE + * GeckoSessionSettings.VIEWPORT_MODE_*} flags. + */ + public void setViewportMode(final int value) { + setInt(VIEWPORT_MODE, value); + } + + private void setInt(final Key<Integer> key, final int value) { + synchronized (mBundle) { + if (valueChangedLocked(key, value)) { + mBundle.putInt(key.name, value); + dispatchUpdate(); + } + } + } + + /** + * Set the window screen ID. Read-only once session is open. Use the {@link Builder} to set on + * session open. + * + * @return Key to set the window screen ID. 0 is the default ID. + */ + public int getScreenId() { + return getInt(SCREEN_ID); + } + + /** + * The current user agent Mode + * + * @return One or more of the {@link GeckoSessionSettings#USER_AGENT_MODE_MOBILE + * GeckoSessionSettings.USER_AGENT_MODE_*} flags. + */ + public int getUserAgentMode() { + return getInt(USER_AGENT_MODE); + } + + /** + * The current display mode. + * + * @return )One or more of the {@link GeckoSessionSettings#DISPLAY_MODE_BROWSER + * GeckoSessionSettings.DISPLAY_MODE_*} flags. + */ + public int getDisplayMode() { + return getInt(DISPLAY_MODE); + } + + /** + * The current viewport Mode + * + * @return One or more of the {@link GeckoSessionSettings#VIEWPORT_MODE + * GeckoSessionSettings.VIEWPORT_MODE_*} flags. + */ + public int getViewportMode() { + return getInt(VIEWPORT_MODE); + } + + private int getInt(final Key<Integer> key) { + synchronized (mBundle) { + return mBundle.getInt(key.name); + } + } + + /** + * Set the chrome URI. + * + * @param value The URI to set the Chrome URI to. + */ + private void setChromeUri(final @NonNull String value) { + setString(CHROME_URI, value); + } + + /** + * Specify the user agent override string. Set value to null to use the user agent specified by + * USER_AGENT_MODE. + * + * @param value The string to override the user agent with. + */ + public void setUserAgentOverride(final @Nullable String value) { + setString(USER_AGENT_OVERRIDE, value); + } + + private void setContextId(final @Nullable String value) { + setString(UNSAFE_CONTEXT_ID, value); + setString(CONTEXT_ID, StorageController.createSafeSessionContextId(value)); + } + + private void setString(final Key<String> key, final String value) { + synchronized (mBundle) { + if (valueChangedLocked(key, value)) { + mBundle.putString(key.name, value); + dispatchUpdate(); + } + } + } + + /** + * Set the chrome window URI. Read-only once session is open. Use the {@link Builder} to set on + * session open. + * + * @return Key to set the chrome window URI, or null to use default URI. + */ + public @Nullable String getChromeUri() { + return getString(CHROME_URI); + } + + /** + * The user agent override string. + * + * @return The current user agent string or null if the agent is specified by {@link + * GeckoSessionSettings#USER_AGENT_MODE} + */ + public @Nullable String getUserAgentOverride() { + return getString(USER_AGENT_OVERRIDE); + } + + private String getString(final Key<String> key) { + synchronized (mBundle) { + return mBundle.getString(key.name); + } + } + + /* package */ @NonNull + GeckoBundle toBundle() { + return new GeckoBundle(mBundle); + } + + @Override + public String toString() { + return mBundle.toString(); + } + + @Override + public boolean equals(final Object other) { + return other instanceof GeckoSessionSettings + && mBundle.equals(((GeckoSessionSettings) other).mBundle); + } + + @Override + public int hashCode() { + return mBundle.hashCode(); + } + + private <T> boolean valueChangedLocked(final Key<T> key, final T value) { + if (key.initOnly && mSession != null) { + throw new IllegalStateException("Read-only property"); + } else if (key.values != null && !key.values.contains(value)) { + throw new IllegalArgumentException("Invalid value"); + } + + final Object old = mBundle.get(key.name); + return (old != value) && (old == null || !old.equals(value)); + } + + private void dispatchUpdate() { + if (mSession != null) { + mSession.getEventDispatcher().dispatch("GeckoView:UpdateSettings", toBundle()); + } + } + + @Override // Parcelable + public int describeContents() { + return 0; + } + + @Override // Parcelable + public void writeToParcel(final @NonNull Parcel out, final int flags) { + mBundle.writeToParcel(out, flags); + } + + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + mBundle.readFromParcel(source); + } + + public static final Parcelable.Creator<GeckoSessionSettings> CREATOR = + new Parcelable.Creator<GeckoSessionSettings>() { + @Override + public GeckoSessionSettings createFromParcel(final Parcel in) { + final GeckoSessionSettings settings = new GeckoSessionSettings(); + settings.readFromParcel(in); + return settings; + } + + @Override + public GeckoSessionSettings[] newArray(final int size) { + return new GeckoSessionSettings[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java new file mode 100644 index 0000000000..754414a0ea --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoVRManager.java @@ -0,0 +1,42 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * Interface for registering the external VR context with WebVR. The context must be registered + * before Gecko is started. This API is not intended for external consumption. To see an example of + * how it is used please see the <a href="https://github.com/MozillaReality/FirefoxReality" + * target="_blank">Firefox Reality browser</a>. + * + * @see <a href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h" + * target="_blank">External VR Context</a> + */ +public class GeckoVRManager { + private static long mExternalContext; + + private GeckoVRManager() {} + + @WrapForJNI + private static synchronized long getExternalContext() { + return mExternalContext; + } + + /** + * Sets the external VR context. The external VR context is defined <a + * href="https://searchfox.org/mozilla-central/source/gfx/vr/external_api/moz_external_vr.h" + * target="_blank">here</a>. + * + * @param externalContext A pointer to the external VR context. + */ + @AnyThread + public static synchronized void setExternalContext(final long externalContext) { + mExternalContext = externalContext; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java new file mode 100644 index 0000000000..0f29e90dde --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoView.java @@ -0,0 +1,1122 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Region; +import android.os.Build; +import android.os.Handler; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.util.TypedValue; +import android.view.DisplayCutout; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.Surface; +import android.view.SurfaceControl; +import android.view.SurfaceView; +import android.view.TextureView; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewStructure; +import android.view.autofill.AutofillManager; +import android.view.autofill.AutofillValue; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.core.view.ViewCompat; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.ref.WeakReference; +import java.util.Objects; +import org.mozilla.gecko.AndroidGamepadManager; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.SurfaceViewWrapper; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class GeckoView extends FrameLayout { + private static final String LOGTAG = "GeckoView"; + private static final boolean DEBUG = false; + + protected final @NonNull Display mDisplay = new Display(); + + private Integer mLastCoverColor; + protected @Nullable GeckoSession mSession; + WeakReference<Autofill.Session> mAutofillSession = new WeakReference<>(null); + + // Whether this GeckoView instance has a session that is no longer valid, e.g. because the session + // associated to this GeckoView was attached to a different GeckoView instance. + private boolean mIsSessionPoisoned = false; + + private boolean mStateSaved; + + private @Nullable SurfaceViewWrapper mSurfaceWrapper; + + private boolean mIsResettingFocus; + + private boolean mAutofillEnabled = true; + + private GeckoSession.SelectionActionDelegate mSelectionActionDelegate; + private Autofill.Delegate mAutofillDelegate; + + private class Display implements SurfaceViewWrapper.Listener { + private final int[] mOrigin = new int[2]; + + private GeckoDisplay mDisplay; + private boolean mValid; + + private int mClippingHeight; + private int mDynamicToolbarMaxHeight; + + public void acquire(final GeckoDisplay display) { + mDisplay = display; + + if (!mValid) { + return; + } + + setVerticalClipping(mClippingHeight); + + // Tell display there is already a surface. + onGlobalLayout(); + if (GeckoView.this.mSurfaceWrapper != null) { + final SurfaceViewWrapper wrapper = GeckoView.this.mSurfaceWrapper; + + // On some devices, we have seen that the Surface can become abandoned sometime in between + // the surfaceChanged callback and attempting to use the Surface here. In such cases, + // rendering in to the Surface will always fail, resulting in the user being presented a + // blank, unresponsive screen or the application crashing. To work around this, check + // whether the Surface is in such a state, and if so toggle the SurfaceView's visibility + // in order to request a new Surface. See bug 1772839. + final boolean isAbandoned = SurfaceViewWrapper.isSurfaceAbandoned(wrapper.getSurface()); + if (isAbandoned && wrapper.getView().getVisibility() == View.VISIBLE) { + wrapper.getView().setVisibility(View.INVISIBLE); + wrapper.getView().setVisibility(View.VISIBLE); + } else { + mDisplay.surfaceChanged( + new GeckoDisplay.SurfaceInfo.Builder(wrapper.getSurface()) + .surfaceControl(wrapper.getSurfaceControl()) + .size(wrapper.getWidth(), wrapper.getHeight()) + .build()); + mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + GeckoView.this.setActive(true); + } + } + } + + public GeckoDisplay release() { + if (mValid) { + if (mDisplay != null) { + mDisplay.surfaceDestroyed(); + } + GeckoView.this.setActive(false); + } + + final GeckoDisplay display = mDisplay; + mDisplay = null; + return display; + } + + @Override // SurfaceListener + public void onSurfaceChanged( + final Surface surface, + @Nullable final SurfaceControl surfaceControl, + final int width, + final int height) { + if (mDisplay != null) { + mDisplay.surfaceChanged( + new GeckoDisplay.SurfaceInfo.Builder(surface) + .surfaceControl(surfaceControl) + .size(width, height) + .build()); + mDisplay.setDynamicToolbarMaxHeight(mDynamicToolbarMaxHeight); + if (!mValid) { + GeckoView.this.setActive(true); + } + } + mValid = true; + } + + @Override // SurfaceListener + public void onSurfaceDestroyed() { + if (mDisplay != null) { + mDisplay.surfaceDestroyed(); + GeckoView.this.setActive(false); + } + mValid = false; + } + + public void onGlobalLayout() { + if (mDisplay == null) { + return; + } + if (GeckoView.this.mSurfaceWrapper != null) { + GeckoView.this.mSurfaceWrapper.getView().getLocationOnScreen(mOrigin); + mDisplay.screenOriginChanged(mOrigin[0], mOrigin[1]); + // cutout support + if (Build.VERSION.SDK_INT >= 28) { + final DisplayCutout cutout = + GeckoView.this.mSurfaceWrapper.getView().getRootWindowInsets().getDisplayCutout(); + if (cutout != null) { + mDisplay.safeAreaInsetsChanged( + cutout.getSafeInsetTop(), + cutout.getSafeInsetRight(), + cutout.getSafeInsetBottom(), + cutout.getSafeInsetLeft()); + } + } + } + } + + public boolean shouldPinOnScreen() { + return mDisplay != null ? mDisplay.shouldPinOnScreen() : false; + } + + public void setVerticalClipping(final int clippingHeight) { + mClippingHeight = clippingHeight; + + if (mDisplay != null) { + mDisplay.setVerticalClipping(clippingHeight); + } + } + + public void setDynamicToolbarMaxHeight(final int height) { + mDynamicToolbarMaxHeight = height; + + // Reset the vertical clipping value to zero whenever we change + // the dynamic toolbar __max__ height so that it can be properly + // propagated to both the main thread and the compositor thread, + // thus we will be able to reset the __current__ toolbar height + // on the both threads whatever the __current__ toolbar height is. + setVerticalClipping(0); + + if (mDisplay != null) { + mDisplay.setDynamicToolbarMaxHeight(height); + } + } + + /** + * Request a {@link Bitmap} of the visible portion of the web page currently being rendered. + * + * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and + * size information of the currently visible rendered web page. + */ + @UiThread + @NonNull + GeckoResult<Bitmap> capturePixels() { + if (mDisplay == null) { + return GeckoResult.fromException( + new IllegalStateException("Display must be created before pixels can be captured")); + } + + return mDisplay.capturePixels(); + } + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoView(final Context context) { + super(context); + init(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public GeckoView(final Context context, final AttributeSet attrs) { + super(context, attrs); + init(); + } + + private static Activity getActivityFromContext(final Context outerContext) { + Context context = outerContext; + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + return null; + } + + private void init() { + setFocusable(true); + setFocusableInTouchMode(true); + setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + + // 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 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); + + mSurfaceWrapper = new SurfaceViewWrapper(getContext()); + mSurfaceWrapper.setBackgroundColor(Color.WHITE); + addView( + mSurfaceWrapper.getView(), + new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + mSurfaceWrapper.setListener(mDisplay); + + final Activity activity = getActivityFromContext(getContext()); + if (activity != null) { + mSelectionActionDelegate = new BasicSelectionActionDelegate(activity); + } + + if (Build.VERSION.SDK_INT >= 26) { + mAutofillDelegate = new AndroidAutofillDelegate(); + } else { + // We don't support Autofill on SDK < 26 + mAutofillDelegate = new Autofill.Delegate() {}; + } + } + + /** + * Set a color to cover the display surface while a document is being shown. The color is + * automatically cleared once the new document starts painting. + * + * @param color Cover color. + */ + public void coverUntilFirstPaint(final int color) { + mLastCoverColor = color; + if (mSession != null) { + mSession.getCompositorController().setClearColor(color); + } + coverUntilFirstPaintInternal(color); + } + + private void uncover() { + coverUntilFirstPaintInternal(Color.TRANSPARENT); + } + + private void coverUntilFirstPaintInternal(final int color) { + ThreadUtils.assertOnUiThread(); + + if (mSurfaceWrapper != null) { + mSurfaceWrapper.setBackgroundColor(color); + } + } + + /** + * This GeckoView instance will be backed by a {@link SurfaceView}. + * + * <p>This option offers the best performance at the price of not being able to animate GeckoView. + */ + public static final int BACKEND_SURFACE_VIEW = 1; + /** + * This GeckoView instance will be backed by a {@link TextureView}. + * + * <p>This option offers worse performance compared to {@link #BACKEND_SURFACE_VIEW} but allows + * you to animate GeckoView or to paint a GeckoView on top of another GeckoView. + */ + public static final int BACKEND_TEXTURE_VIEW = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({BACKEND_SURFACE_VIEW, BACKEND_TEXTURE_VIEW}) + public @interface ViewBackend {} + + /** + * Set which view should be used by this GeckoView instance to display content. + * + * <p>By default, GeckoView will use a {@link SurfaceView}. + * + * @param backend Any of {@link #BACKEND_SURFACE_VIEW BACKEND_*}. + */ + public void setViewBackend(final @ViewBackend int backend) { + removeView(mSurfaceWrapper.getView()); + + if (backend == BACKEND_SURFACE_VIEW) { + mSurfaceWrapper.useSurfaceView(getContext()); + } else if (backend == BACKEND_TEXTURE_VIEW) { + mSurfaceWrapper.useTextureView(getContext()); + } + + addView(mSurfaceWrapper.getView()); + + if (mSession != null) { + mSession.getMagnifier().setView(mSurfaceWrapper.getView()); + } + } + + /** + * Return whether the view should be pinned on the screen. When pinned, the view should not be + * moved on the screen due to animation, scrolling, etc. A common reason for the view being pinned + * is when the user is dragging a selection caret inside the view; normal user interaction would + * be disrupted in that case if the view was moved on screen. + * + * @return True if view should be pinned on the screen. + */ + public boolean shouldPinOnScreen() { + ThreadUtils.assertOnUiThread(); + + return mDisplay.shouldPinOnScreen(); + } + + /** + * Update the amount of vertical space that is clipped or visibly obscured in the bottom portion + * of the view. Tells gecko where to put bottom fixed elements so they are fully visible. + * + * <p>Optional call. The display's visible vertical space has changed. Must be called on the + * application main thread. + * + * @param clippingHeight The height of the bottom clipped space in screen pixels. + */ + public void setVerticalClipping(final int clippingHeight) { + ThreadUtils.assertOnUiThread(); + + mDisplay.setVerticalClipping(clippingHeight); + } + + /** + * Set the maximum height of the dynamic toolbar(s). + * + * <p>If there are two or more dynamic toolbars, the height value should be the total amount of + * the height of each dynamic toolbar. + * + * @param height The the maximum height of the dynamic toolbar(s). + */ + public void setDynamicToolbarMaxHeight(final int height) { + mDisplay.setDynamicToolbarMaxHeight(height); + } + + /* package */ void setActive(final boolean active) { + if (mSession != null) { + mSession.setActive(active); + } + } + + // TODO: Bug 1670805 this should really be configurable + // Default dark color for about:blank, keep it in sync with PresShell.cpp + static final int DEFAULT_DARK_COLOR = 0xFF2A2A2E; + + private int defaultColor() { + // If the app set a default color, just use that + if (mLastCoverColor != null) { + return mLastCoverColor; + } + + if (mSession == null || !mSession.isOpen()) { + return Color.WHITE; + } + + // ... otherwise use the prefers-color-scheme color + return mSession.getRuntime().usesDarkTheme() ? DEFAULT_DARK_COLOR : Color.WHITE; + } + + /** + * Unsets the current session from this instance and returns it, if any. You must call this before + * {@link #setSession(GeckoSession)} if there is already an open session set for this instance. + * + * <p>Note: this method does not close the session and the session remains active. The caller is + * responsible for calling {@link GeckoSession#close()} when appropriate. + * + * @return The {@link GeckoSession} that was set for this instance. May be null. + */ + @UiThread + public @Nullable GeckoSession releaseSession() { + ThreadUtils.assertOnUiThread(); + + if (mSession == null) { + return null; + } + + final GeckoSession session = mSession; + mSession.releaseDisplay(mDisplay.release()); + mSession.getOverscrollEdgeEffect().setInvalidationCallback(null); + mSession.getOverscrollEdgeEffect().setSession(null); + mSession.getCompositorController().setFirstPaintCallback(null); + + if (mSession.getAccessibility().getView() == this) { + mSession.getAccessibility().setView(null); + } + + if (mSession.getTextInput().getView() == this) { + mSession.getTextInput().setView(null); + } + + if (mSession.getSelectionActionDelegate() == mSelectionActionDelegate) { + mSession.setSelectionActionDelegate(null); + } + + if (mSession.getAutofillDelegate() == mAutofillDelegate) { + mSession.setAutofillDelegate(null); + } + + if (mSession.getMagnifier().getView() == mSurfaceWrapper.getView()) { + session.getMagnifier().setView(null); + } + + if (isFocused()) { + mSession.setFocused(false); + } + mSession = null; + mIsSessionPoisoned = false; + session.releaseOwner(); + return session; + } + + private final GeckoSession.Owner mSessionOwner = + new GeckoSession.Owner() { + @Override + public void onRelease() { + // The session that we own is being owned by some other object so we need to release it + // here. + releaseSession(); + // The session associated to this GeckoView is now invalid, but the app is not aware of + // it. We cannot display this GeckoView until the app sets a session again (or releases + // the poisoned session). + mIsSessionPoisoned = true; + } + }; + + /** + * Attach a session to this view. If this instance already has an open session, you must use + * {@link #releaseSession()} first, otherwise {@link IllegalStateException} will be thrown. This + * is to avoid potentially leaking the currently opened session. + * + * @param session The session to be attached. + * @throws IllegalArgumentException if an existing open session is already set. + */ + @UiThread + public void setSession(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + + if (session == mSession) { + // Nothing to do + return; + } + + releaseSession(); + + session.setOwner(mSessionOwner); + mSession = session; + mIsSessionPoisoned = false; + + // Make sure the clear color is set to the default + mSession.getCompositorController().setClearColor(defaultColor()); + + if (ViewCompat.isAttachedToWindow(this)) { + mDisplay.acquire(session.acquireDisplay()); + } + + final Context context = getContext(); + session.getOverscrollEdgeEffect().setTheme(context); + session.getOverscrollEdgeEffect().setSession(session); + session + .getOverscrollEdgeEffect() + .setInvalidationCallback( + new Runnable() { + @Override + public void run() { + if (Build.VERSION.SDK_INT >= 16) { + GeckoView.this.postInvalidateOnAnimation(); + } else { + GeckoView.this.postInvalidateDelayed(10); + } + } + }); + + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final TypedValue outValue = new TypedValue(); + if (context + .getTheme() + .resolveAttribute(android.R.attr.listPreferredItemHeight, outValue, true)) { + session.getPanZoomController().setScrollFactor(outValue.getDimension(metrics)); + } else { + session.getPanZoomController().setScrollFactor(0.075f * metrics.densityDpi); + } + + session.getCompositorController().setFirstPaintCallback(this::uncover); + + if (session.getTextInput().getView() == null) { + session.getTextInput().setView(this); + } + + if (session.getAccessibility().getView() == null) { + session.getAccessibility().setView(this); + } + + if (session.getSelectionActionDelegate() == null && mSelectionActionDelegate != null) { + session.setSelectionActionDelegate(mSelectionActionDelegate); + } + + if (mAutofillEnabled) { + session.setAutofillDelegate(mAutofillDelegate); + } + + if (session.getMagnifier().getView() == null) { + session.getMagnifier().setView(mSurfaceWrapper.getView()); + } + + if (isFocused()) { + session.setFocused(true); + } + } + + @AnyThread + @SuppressWarnings("checkstyle:javadocmethod") + public @Nullable GeckoSession getSession() { + return mSession; + } + + @AnyThread + /* package */ @NonNull + EventDispatcher getEventDispatcher() { + return mSession.getEventDispatcher(); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public @NonNull PanZoomController getPanZoomController() { + ThreadUtils.assertOnUiThread(); + return mSession.getPanZoomController(); + } + + @Override + public void onAttachedToWindow() { + if (mIsSessionPoisoned) { + throw new IllegalStateException("Trying to display a view with invalid session."); + } + if (mSession != null) { + final GeckoRuntime runtime = mSession.getRuntime(); + if (runtime != null) { + runtime.orientationChanged(); + } + } + + if (mSession != null) { + mDisplay.acquire(mSession.acquireDisplay()); + } + + super.onAttachedToWindow(); + } + + @Override + public void onDetachedFromWindow() { + super.onDetachedFromWindow(); + + if (mSession == null) { + return; + } + + // Release the display before we detach from the window. + mSession.releaseDisplay(mDisplay.release()); + } + + @Override + protected void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + if (mSession != null) { + final GeckoRuntime runtime = mSession.getRuntime(); + if (runtime != null) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 + || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // onConfigurationChanged is not called for 180 degree orientation changes, + // we will miss such rotations and the screen orientation will not be + // updated. + // + // If API is 17+, we use DisplayManager API to detect all degree + // orientation change. But if API is 31+, DisplayManager API may report previous + // information. So we have to report it again. + runtime.orientationChanged(newConfig.orientation); + } + + runtime.configurationChanged(newConfig); + } + } + } + + @Override + public boolean gatherTransparentRegion(final Region region) { + // For detecting changes in SurfaceView layout, we take a shortcut here and + // override gatherTransparentRegion, instead of registering a layout listener, + // which is more expensive. + if (mSurfaceWrapper != null) { + mDisplay.onGlobalLayout(); + } + return super.gatherTransparentRegion(region); + } + + @Override + public void onWindowFocusChanged(final boolean hasWindowFocus) { + super.onWindowFocusChanged(hasWindowFocus); + + // Only call setFocus(true) when the window gains focus. Any focus loss could be temporary + // (e.g. due to auto-fill popups) and we don't want to call setFocus(false) in those cases. + // Instead, we call setFocus(false) in onWindowVisibilityChanged. + if (mSession != null && hasWindowFocus && isFocused()) { + mSession.setFocused(true); + } + } + + @Override + protected void onWindowVisibilityChanged(final int visibility) { + super.onWindowVisibilityChanged(visibility); + + // We can be reasonably sure that the focus loss is not temporary, so call setFocus(false). + if (mSession != null && visibility != View.VISIBLE && !hasWindowFocus()) { + mSession.setFocused(false); + } + } + + @Override + protected void onFocusChanged( + final boolean gainFocus, final int direction, final Rect previouslyFocusedRect) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); + + if (mIsResettingFocus) { + return; + } + + if (mSession != null) { + mSession.setFocused(gainFocus); + } + + if (!gainFocus) { + return; + } + + post( + new Runnable() { + @Override + public void run() { + if (!isFocused()) { + return; + } + + final InputMethodManager imm = InputMethods.getInputMethodManager(getContext()); + // Bug 1404111: Through View#onFocusChanged, the InputMethodManager queues + // up a checkFocus call for the next spin of the message loop, so by + // posting this Runnable after super#onFocusChanged, the IMM should have + // completed its focus change handling at this point and we should be the + // active view for input handling. + + // If however onViewDetachedFromWindow for the previously active view gets + // called *after* onFocusChanged, but *before* the focus change has been + // fully processed by the IMM with the help of checkFocus, the IMM will + // lose track of the currently active view, which means that we can't + // interact with the IME. + if (!imm.isActive(GeckoView.this)) { + // If that happens, we bring the IMM's internal state back into sync + // by clearing and resetting our focus. + mIsResettingFocus = true; + clearFocus(); + // After calling clearFocus we might regain focus automatically, but + // we explicitly request it again in case this doesn't happen. If + // we've already got the focus back, this will then be a no-op anyway. + requestFocus(); + mIsResettingFocus = false; + } + } + }); + } + + @Override + public Handler getHandler() { + if (Build.VERSION.SDK_INT >= 24 || mSession == null) { + return super.getHandler(); + } + return mSession.getTextInput().getHandler(super.getHandler()); + } + + @Override + public InputConnection onCreateInputConnection(final EditorInfo outAttrs) { + if (mSession == null) { + return null; + } + return mSession.getTextInput().onCreateInputConnection(outAttrs); + } + + @Override + public boolean onKeyPreIme(final int keyCode, final KeyEvent event) { + if (super.onKeyPreIme(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyPreIme(keyCode, event); + } + + @Override + public boolean onKeyUp(final int keyCode, final KeyEvent event) { + if (super.onKeyUp(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyUp(keyCode, event); + } + + @Override + public boolean onKeyDown(final int keyCode, final KeyEvent event) { + if (super.onKeyDown(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyDown(keyCode, event); + } + + @Override + public boolean onKeyLongPress(final int keyCode, final KeyEvent event) { + if (super.onKeyLongPress(keyCode, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyLongPress(keyCode, event); + } + + @Override + public boolean onKeyMultiple(final int keyCode, final int repeatCount, final KeyEvent event) { + if (super.onKeyMultiple(keyCode, repeatCount, event)) { + return true; + } + return mSession != null && mSession.getTextInput().onKeyMultiple(keyCode, repeatCount, event); + } + + @Override + public void dispatchDraw(final @Nullable Canvas canvas) { + super.dispatchDraw(canvas); + + if (mSession != null) { + mSession.getOverscrollEdgeEffect().draw(canvas); + } + } + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mSession == null) { + return false; + } + + mSession.getPanZoomController().onTouchEvent(event); + return true; + } + + /** + * Dispatches a {@link MotionEvent} to the {@link PanZoomController}. This is the same as {@link + * #onTouchEvent(MotionEvent)}, but instead returns a {@link PanZoomController.InputResult} + * indicating how the event was handled. + * + * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited + * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and + * unnecessary GC pressure. + * + * @param event A {@link MotionEvent} + * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}. + */ + public @NonNull GeckoResult<PanZoomController.InputResultDetail> onTouchEventForDetailResult( + final @NonNull MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + requestFocus(); + } + + if (mSession == null) { + return GeckoResult.fromValue( + new PanZoomController.InputResultDetail( + PanZoomController.INPUT_RESULT_UNHANDLED, + PanZoomController.SCROLLABLE_FLAG_NONE, + PanZoomController.OVERSCROLL_FLAG_NONE)); + } + + // NOTE: Treat mouse events as "touch" rather than as "mouse", so mouse can be + // used to pan/zoom. Call onMouseEvent() instead for behavior similar to desktop. + return mSession.getPanZoomController().onTouchEventForDetailResult(event); + } + + @Override + public boolean onGenericMotionEvent(final MotionEvent event) { + if (AndroidGamepadManager.handleMotionEvent(event)) { + return true; + } + + if (mSession == null) { + return true; + } + + if (mSession.getAccessibility().onMotionEvent(event)) { + return true; + } + + mSession.getPanZoomController().onMotionEvent(event); + return true; + } + + @Override + public void onProvideAutofillVirtualStructure(final ViewStructure structure, final int flags) { + if (mSession == null) { + return; + } + + final Autofill.Session autofillSession = mSession.getAutofillSession(); + + // Let's store the session here in case we need to autofill it later + mAutofillSession = new WeakReference<>(autofillSession); + autofillSession.fillViewStructure(this, structure, flags); + } + + @Override + @TargetApi(26) + public void autofill(@NonNull final SparseArray<AutofillValue> values) { + // Note: we can't use mSession.getAutofillSession() because the app might have swapped + // the session under us between the onProvideAutofillVirtualStructure and this call + // so mSession could refer to a different session or we might not have a session at all. + final Autofill.Session session = mAutofillSession.get(); + if (session == null) { + return; + } + final SparseArray<CharSequence> strValues = new SparseArray<>(values.size()); + for (int i = 0; i < values.size(); i++) { + final AutofillValue value = values.valueAt(i); + if (value.isText()) { + // Only text is currently supported. + strValues.put(values.keyAt(i), value.getTextValue()); + } + } + session.autofill(strValues); + } + + @Override + public boolean isVisibleToUserForAutofill(final int virtualId) { + // If autofill service works with compatibility mode, + // View.isVisibleToUserForAutofill walks through the accessibility nodes. + // This override avoids it. + return true; + } + + /** + * Request a {@link Bitmap} of the visible portion of the web page currently being rendered. + * + * <p>See {@link GeckoDisplay#capturePixels} for more details. + * + * @return A {@link GeckoResult} that completes with a {@link Bitmap} containing the pixels and + * size information of the currently visible rendered web page. + */ + @UiThread + public @NonNull GeckoResult<Bitmap> capturePixels() { + return mDisplay.capturePixels(); + } + + /** + * Sets whether or not this View participates in Android autofill. + * + * <p>When enabled, this will set an {@link Autofill.Delegate} on the {@link GeckoSession} for + * this instance. + * + * @param enabled Whether or not Android autofill is enabled for this view. + */ + @TargetApi(26) + public void setAutofillEnabled(final boolean enabled) { + mAutofillEnabled = enabled; + + if (mSession != null) { + if (!enabled && mSession.getAutofillDelegate() == mAutofillDelegate) { + mSession.setAutofillDelegate(null); + } else if (enabled) { + mSession.setAutofillDelegate(mAutofillDelegate); + } + } + } + + /** + * @return Whether or not Android autofill is enabled for this view. + */ + @TargetApi(26) + public boolean getAutofillEnabled() { + return mAutofillEnabled; + } + + @TargetApi(26) + private class AndroidAutofillDelegate implements Autofill.Delegate { + AutofillManager mAutofillManager; + boolean mDisabled = false; + + private void ensureAutofillManager() { + if (mDisabled || mAutofillManager != null) { + // Nothing to do + return; + } + + mAutofillManager = GeckoView.this.getContext().getSystemService(AutofillManager.class); + if (mAutofillManager == null) { + // If we can't get a reference to the autofill manager, we cannot run the autofill service + mDisabled = true; + } + } + + private Rect displayRectForId( + @NonNull final GeckoSession session, @Nullable final Autofill.Node node) { + if (node == null) { + return new Rect(0, 0, 0, 0); + } + + if (!node.getScreenRect().isEmpty()) { + return node.getScreenRect(); + } + + final Matrix matrix = new Matrix(); + final RectF rectF = new RectF(node.getDimensions()); + session.getPageToScreenMatrix(matrix); + matrix.mapRect(rectF); + + final Rect screenRect = new Rect(); + rectF.roundOut(screenRect); + return screenRect; + } + + @Override + public void onNodeBlur( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node prev, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewExited(GeckoView.this, data.getId()); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewExited: ", e); + } + } + + @Override + public void onNodeAdd( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + if (!mSession.getAutofillSession().isVisible(node)) { + return; + } + final Autofill.Node focused = mSession.getAutofillSession().getFocused(); + // We must have a focused node because |node| is visible + Objects.requireNonNull(focused); + + final Autofill.NodeData focusedData = mSession.getAutofillSession().dataFor(focused); + Objects.requireNonNull(focusedData); + + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewExited(GeckoView.this, focusedData.getId()); + mAutofillManager.notifyViewEntered( + GeckoView.this, focusedData.getId(), displayRectForId(session, focused)); + } catch (final SecurityException e) { + Log.e( + LOGTAG, + "Failed to call AutofillManager.notifyViewExited or AutofillManager.notifyViewEntered: ", + e); + } + } + + @Override + public void onNodeFocus( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node focused, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyViewEntered( + GeckoView.this, data.getId(), displayRectForId(session, focused)); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyViewEntered: ", e); + } + } + + @Override + public void onNodeRemove( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) {} + + @Override + public void onNodeUpdate( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.notifyValueChanged( + GeckoView.this, data.getId(), AutofillValue.forText(data.getValue())); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.notifyValueChanged: ", e); + } + } + + @Override + public void onSessionCancel(final @NonNull GeckoSession session) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + // This line seems necessary for auto-fill to work on the initial page. + mAutofillManager.cancel(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e); + } + } + + @Override + public void onSessionCommit( + final @NonNull GeckoSession session, + final @NonNull Autofill.Node node, + final @NonNull Autofill.NodeData data) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + mAutofillManager.commit(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.commit: ", e); + } + } + + @Override + public void onSessionStart(final @NonNull GeckoSession session) { + ensureAutofillManager(); + if (mAutofillManager == null) { + return; + } + try { + // This line seems necessary for auto-fill to work on the initial page. + mAutofillManager.cancel(); + } catch (final SecurityException e) { + Log.e(LOGTAG, "Failed to call AutofillManager.cancel: ", e); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java new file mode 100644 index 0000000000..1546451056 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/GeckoWebExecutor.java @@ -0,0 +1,189 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.net.InetAddress; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Locale; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * GeckoWebExecutor is responsible for fetching a {@link WebRequest} and delivering a {@link + * WebResponse} to the caller via {@link #fetch(WebRequest)}. Example: + * + * <pre> + * final GeckoWebExecutor executor = new GeckoWebExecutor(); + * + * final GeckoResult<WebResponse> result = executor.fetch( + * new WebRequest.Builder("https://example.org/json") + * .header("Accept", "application/json") + * .build()); + * + * result.then(response -> { + * // Do something with response + * }); + * </pre> + */ +@AnyThread +public class GeckoWebExecutor { + // We don't use this right now because we access GeckoThread directly, but + // it's future-proofing for a world where we allow multiple GeckoRuntimes. + private final GeckoRuntime mRuntime; + + @WrapForJNI(dispatchTo = "gecko", stubName = "Fetch") + private static native void nativeFetch( + WebRequest request, int flags, GeckoResult<WebResponse> result); + + @WrapForJNI(dispatchTo = "gecko", stubName = "Resolve") + private static native void nativeResolve(String host, GeckoResult<InetAddress[]> result); + + @WrapForJNI(calledFrom = "gecko", exceptionMode = "nsresult") + private static ByteBuffer createByteBuffer(final int capacity) { + return ByteBuffer.allocateDirect(capacity); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + FETCH_FLAGS_NONE, + FETCH_FLAGS_ANONYMOUS, + FETCH_FLAGS_NO_REDIRECTS, + FETCH_FLAGS_PRIVATE, + FETCH_FLAGS_STREAM_FAILURE_TEST, + }) + public @interface FetchFlags {} + + /** No special treatment. */ + public static final int FETCH_FLAGS_NONE = 0; + + /** Don't send cookies or other user data along with the request. */ + @WrapForJNI public static final int FETCH_FLAGS_ANONYMOUS = 1; + + /** Don't automatically follow redirects. */ + @WrapForJNI public static final int FETCH_FLAGS_NO_REDIRECTS = 1 << 1; + + // There was supposed to be another flag, which we then decided not to implement. + // That's the reason there's no value 1 << 2, and it can absolutely be used :) + + /** Associates this download with the current private browsing session */ + @WrapForJNI public static final int FETCH_FLAGS_PRIVATE = 1 << 3; + + /** This flag causes a read error in the {@link WebResponse} body. Useful for testing. */ + @WrapForJNI public static final int FETCH_FLAGS_STREAM_FAILURE_TEST = 1 << 10; + + /** + * Create a new GeckoWebExecutor instance. + * + * @param runtime A GeckoRuntime instance + */ + public GeckoWebExecutor(final @NonNull GeckoRuntime runtime) { + mRuntime = runtime; + } + + /** + * Send the given {@link WebRequest}. + * + * @param request A {@link WebRequest} instance + * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the + * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a + * {@link WebRequestError}. + * @throws IllegalArgumentException if request is null or otherwise unusable. + */ + public @NonNull GeckoResult<WebResponse> fetch(final @NonNull WebRequest request) { + return fetch(request, FETCH_FLAGS_NONE); + } + + /** + * Send the given {@link WebRequest} with specified flags. + * + * @param request A {@link WebRequest} instance + * @param flags The specified flags. One or more of the {@link #FETCH_FLAGS_NONE FETCH_*} flags. + * @return A {@link GeckoResult} which will be completed with a {@link WebResponse}. If the + * request fails to complete, the {@link GeckoResult} will be completed exceptionally with a + * {@link WebRequestError}. + * @throws IllegalArgumentException if request is null or otherwise unusable. + */ + public @NonNull GeckoResult<WebResponse> fetch( + final @NonNull WebRequest request, final @FetchFlags int flags) { + if (request.body != null && !request.body.isDirect()) { + throw new IllegalArgumentException("Request body must be a direct ByteBuffer"); + } + + if (request.cacheMode < WebRequest.CACHE_MODE_FIRST + || request.cacheMode > WebRequest.CACHE_MODE_LAST) { + throw new IllegalArgumentException("Unknown cache mode"); + } + + final String uri = request.uri.toLowerCase(Locale.ROOT); + // We don't need to fully validate the URI here, just a sanity check + if (!uri.startsWith("http") && !uri.startsWith("blob")) { + throw new IllegalArgumentException( + "Unsupported URI scheme: " + (uri.length() > 10 ? uri.substring(0, 10) : uri)); + } + + final GeckoResult<WebResponse> result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeFetch(request, flags, result); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + this, + "nativeFetch", + WebRequest.class, + request, + flags, + GeckoResult.class, + result); + } + + return result; + } + + /** + * Resolves the specified host name. + * + * @param host An Internet host name, e.g. mozilla.org. + * @return A {@link GeckoResult} which will be fulfilled with a {@link List} of {@link + * InetAddress}. In case of failure, the {@link GeckoResult} will be completed exceptionally + * with a {@link java.net.UnknownHostException}. + */ + public @NonNull GeckoResult<InetAddress[]> resolve(final @NonNull String host) { + final GeckoResult<InetAddress[]> result = new GeckoResult<>(); + + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + nativeResolve(host, result); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + this, + "nativeResolve", + String.class, + host, + GeckoResult.class, + result); + } + return result; + } + + /** + * This causes a speculative connection to be made to the host in the specified URI. This is + * useful if an app thinks it may be making a request to that host in the near future. If no + * request is made, the connection will be cleaned up after an unspecified amount of time. + * + * @param uri A URI String. + */ + public void speculativeConnect(final @NonNull String uri) { + GeckoThread.speculativeConnect(uri); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java new file mode 100644 index 0000000000..34bf6b0161 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/Image.java @@ -0,0 +1,54 @@ +/* -*- 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.geckoview; + +import android.graphics.Bitmap; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ImageResource; + +/** Represents an Web API image resource as used in web app manifests and media session metadata. */ +@AnyThread +public class Image { + private final ImageResource.Collection mCollection; + + /* package */ Image(final ImageResource.Collection collection) { + mCollection = collection; + } + + /* package */ static Image fromSizeSrcBundle(final GeckoBundle bundle) { + return new Image(ImageResource.Collection.fromSizeSrcBundle(bundle)); + } + + /** + * Get the best version of this image for size <code>size</code>. Embedders are encouraged to + * cache the result of this method keyed with this instance. + * + * @param size pixel size at which this image will be displayed at. + * @return A {@link GeckoResult} that resolves to the bitmap when ready. Will resolve + * exceptionally to {@link ImageProcessingException} if the image cannot be processed. + */ + @NonNull + public GeckoResult<Bitmap> getBitmap(final int size) { + return mCollection.getBitmap(size); + } + + /** Thrown whenever an image cannot be processed by {@link #getBitmap} */ + @WrapForJNI + public static class ImageProcessingException extends RuntimeException { + /** + * Build an instance of this class. + * + * @param message description of the error. + */ + public ImageProcessingException(final String message) { + super(message); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java new file mode 100644 index 0000000000..49d713d4f3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/MediaSession.java @@ -0,0 +1,639 @@ +/* -*- 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.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ImageResource; + +/** + * The MediaSession API provides media controls and events for a GeckoSession. This includes support + * for the DOM Media Session API and regular HTML media content. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/MediaSession">Media Session + * API</a> + */ +@UiThread +public class MediaSession { + private static final String LOGTAG = "MediaSession"; + private static final boolean DEBUG = false; + + private final GeckoSession mSession; + private boolean mIsActive; + + protected MediaSession(final GeckoSession session) { + mSession = session; + } + + /** + * Get whether the media session is active. Only active media sessions can be controlled. + * + * <p>Changes in the active state are notified via {@link Delegate#onActivated} and {@link + * Delegate#onDeactivated} respectively. + * + * @see MediaSession.Delegate#onActivated + * @see MediaSession.Delegate#onDeactivated + * @return True if this media session is active, false otherwise. + */ + public boolean isActive() { + return mIsActive; + } + + /* package */ void setActive(final boolean active) { + mIsActive = active; + } + + /** Pause playback for the media session. */ + public void pause() { + if (DEBUG) { + Log.d(LOGTAG, "pause"); + } + mSession.getEventDispatcher().dispatch(PAUSE_EVENT, null); + } + + /** Stop playback for the media session. */ + public void stop() { + if (DEBUG) { + Log.d(LOGTAG, "stop"); + } + mSession.getEventDispatcher().dispatch(STOP_EVENT, null); + } + + /** Start playback for the media session. */ + public void play() { + if (DEBUG) { + Log.d(LOGTAG, "play"); + } + mSession.getEventDispatcher().dispatch(PLAY_EVENT, null); + } + + /** + * Seek to a specific time. Prefer using fast seeking when calling this in a sequence. Don't use + * fast seeking for the last or only call in a sequence. + * + * @param time The time in seconds to move the playback time to. + * @param fast Whether fast seeking should be used. + */ + public void seekTo(final double time, final boolean fast) { + if (DEBUG) { + Log.d(LOGTAG, "seekTo: time=" + time + ", fast=" + fast); + } + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putDouble("time", time); + bundle.putBoolean("fast", fast); + mSession.getEventDispatcher().dispatch(SEEK_TO_EVENT, bundle); + } + + /** Seek forward by a sensible number of seconds. */ + public void seekForward() { + if (DEBUG) { + Log.d(LOGTAG, "seekForward"); + } + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putDouble("offset", 0.0); + mSession.getEventDispatcher().dispatch(SEEK_FORWARD_EVENT, bundle); + } + + /** Seek backward by a sensible number of seconds. */ + public void seekBackward() { + if (DEBUG) { + Log.d(LOGTAG, "seekBackward"); + } + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putDouble("offset", 0.0); + mSession.getEventDispatcher().dispatch(SEEK_BACKWARD_EVENT, bundle); + } + + /** + * Select and play the next track. Move playback to the next item in the playlist when supported. + */ + public void nextTrack() { + if (DEBUG) { + Log.d(LOGTAG, "nextTrack"); + } + mSession.getEventDispatcher().dispatch(NEXT_TRACK_EVENT, null); + } + + /** + * Select and play the previous track. Move playback to the previous item in the playlist when + * supported. + */ + public void previousTrack() { + if (DEBUG) { + Log.d(LOGTAG, "previousTrack"); + } + mSession.getEventDispatcher().dispatch(PREV_TRACK_EVENT, null); + } + + /** Skip the advertisement that is currently playing. */ + public void skipAd() { + if (DEBUG) { + Log.d(LOGTAG, "skipAd"); + } + mSession.getEventDispatcher().dispatch(SKIP_AD_EVENT, null); + } + + /** + * Set whether audio should be muted. Muting audio is supported by default and does not require + * the media session to be active. + * + * @param mute True if audio for this media session should be muted. + */ + public void muteAudio(final boolean mute) { + if (DEBUG) { + Log.d(LOGTAG, "muteAudio=" + mute); + } + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putBoolean("mute", mute); + mSession.getEventDispatcher().dispatch(MUTE_AUDIO_EVENT, bundle); + } + + /** Implement this delegate to receive media session events. */ + @UiThread + public interface Delegate { + /** + * Notify that the given media session has become active. It is always the first event + * dispatched for a new or previously deactivated media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onActivated( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify that the given media session has become inactive. Inactive media sessions can not be + * controlled. + * + * <p>TODO: Add settings links to control behavior. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onDeactivated( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify on updated metadata. Metadata may be provided by content via the DOM API or by + * GeckoView when not availble. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param meta The updated metadata. + */ + default void onMetadata( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @NonNull final Metadata meta) {} + + /** + * Notify on updated supported features. Unsupported actions will have no effect. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param features A combination of {@link Feature}. + */ + default void onFeatures( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @MSFeature final long features) {} + + /** + * Notify that playback has started for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onPlay( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify that playback has paused for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onPause( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify that playback has stopped for the given media session. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + */ + default void onStop( + @NonNull final GeckoSession session, @NonNull final MediaSession mediaSession) {} + + /** + * Notify on updated position state. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param state An instance of {@link PositionState}. + */ + default void onPositionState( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + @NonNull final PositionState state) {} + + /** + * Notify on changed fullscreen state. + * + * @param session The associated GeckoSession. + * @param mediaSession The media session for the given GeckoSession. + * @param enabled True when this media session in in fullscreen mode. + * @param meta An instance of {@link ElementMetadata}, if enabled. + */ + default void onFullscreen( + @NonNull final GeckoSession session, + @NonNull final MediaSession mediaSession, + final boolean enabled, + @Nullable final ElementMetadata meta) {} + } + + /** The representation of a media element's metadata. */ + public static class ElementMetadata { + /** The media source URI. */ + public final @Nullable String source; + + /** The duration of the media in seconds. 0.0 if unknown. */ + public final double duration; + + /** The width of the video in device pixels. 0 if unknown. */ + public final long width; + + /** The height of the video in device pixels. 0 if unknown. */ + public final long height; + + /** The number of audio tracks contained in this element. */ + public final int audioTrackCount; + + /** The number of video tracks contained in this element. */ + public final int videoTrackCount; + + /** + * ElementMetadata constructor. + * + * @param source The media URI. + * @param duration The media duration in seconds. + * @param width The video width in device pixels. + * @param height The video height in device pixels. + * @param audioTrackCount The audio track count. + * @param videoTrackCount The video track count. + */ + public ElementMetadata( + @Nullable final String source, + final double duration, + final long width, + final long height, + final int audioTrackCount, + final int videoTrackCount) { + this.source = source; + this.duration = duration; + this.width = width; + this.height = height; + this.audioTrackCount = audioTrackCount; + this.videoTrackCount = videoTrackCount; + } + + /* package */ static @NonNull ElementMetadata fromBundle(final GeckoBundle bundle) { + // Sync with MediaUtils.jsm. + return new ElementMetadata( + bundle.getString("src"), + bundle.getDouble("duration", 0.0), + bundle.getLong("width", 0), + bundle.getLong("height", 0), + bundle.getInt("audioTrackCount", 0), + bundle.getInt("videoTrackCount", 0)); + } + } + + /** The representation of a media session's metadata. */ + public static class Metadata { + /** The media title. May be backfilled based on the document's title. May be null or empty. */ + public final @Nullable String title; + + /** The media artist name. May be null or empty. */ + public final @Nullable String artist; + + /** The media album title. May be null or empty. */ + public final @Nullable String album; + + /** The media artwork image. May be null. */ + public final @Nullable Image artwork; + + /** + * Metadata constructor. + * + * @param title The media title string. + * @param artist The media artist string. + * @param album The media album string. + * @param artwork The media artwork {@link Image}. + */ + protected Metadata( + final @Nullable String title, + final @Nullable String artist, + final @Nullable String album, + final @Nullable Image artwork) { + this.title = title; + this.artist = artist; + this.album = album; + this.artwork = artwork; + } + + @AnyThread + /* package */ static final class Builder { + private final GeckoBundle mBundle; + + public Builder(final GeckoBundle bundle) { + mBundle = new GeckoBundle(bundle); + } + + public Builder(final Metadata meta) { + mBundle = meta.toBundle(); + } + + @NonNull + Builder title(final @Nullable String title) { + mBundle.putString("title", title); + return this; + } + + @NonNull + Builder artist(final @Nullable String artist) { + mBundle.putString("artist", artist); + return this; + } + + @NonNull + Builder album(final @Nullable String album) { + mBundle.putString("album", album); + return this; + } + } + + /* package */ static @NonNull Metadata fromBundle(final GeckoBundle bundle) { + final GeckoBundle[] artworkBundles = bundle.getBundleArray("artwork"); + + final ImageResource.Collection.Builder artworkBuilder = + new ImageResource.Collection.Builder(); + + for (final GeckoBundle artworkBundle : artworkBundles) { + artworkBuilder.add(ImageResource.fromBundle(artworkBundle)); + } + + return new Metadata( + bundle.getString("title"), + bundle.getString("artist"), + bundle.getString("album"), + new Image(artworkBuilder.build())); + } + + /* package */ @NonNull + GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(3); + bundle.putString("title", title); + bundle.putString("artist", artist); + bundle.putString("album", album); + return bundle; + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("Metadata {"); + builder + .append(", title=") + .append(title) + .append(", artist=") + .append(artist) + .append(", album=") + .append(album) + .append(", artwork=") + .append(artwork) + .append("}"); + return builder.toString(); + } + } + + /** Holds the details of the media session's playback state. */ + public static class PositionState { + /** The duration of the media in seconds. */ + public final double duration; + + /** The last reported media playback position in seconds. */ + public final double position; + + /** + * The media playback rate coefficient. The rate is positive for forward and negative for + * backward playback. + */ + public final double playbackRate; + + /** + * PositionState constructor. + * + * @param duration The media duration in seconds. + * @param position The current media playback position in seconds. + * @param playbackRate The playback rate coefficient. + */ + protected PositionState( + final double duration, final double position, final double playbackRate) { + this.duration = duration; + this.position = position; + this.playbackRate = playbackRate; + } + + /* package */ static @NonNull PositionState fromBundle(final GeckoBundle bundle) { + return new PositionState( + bundle.getDouble("duration"), + bundle.getDouble("position"), + bundle.getDouble("playbackRate")); + } + + @Override + public String toString() { + final StringBuilder builder = new StringBuilder("PositionState {"); + builder + .append("duration=") + .append(duration) + .append(", position=") + .append(position) + .append(", playbackRate=") + .append(playbackRate) + .append("}"); + return builder.toString(); + } + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = { + Feature.NONE, + Feature.PLAY, + Feature.PAUSE, + Feature.STOP, + Feature.SEEK_TO, + Feature.SEEK_FORWARD, + Feature.SEEK_BACKWARD, + Feature.SKIP_AD, + Feature.NEXT_TRACK, + Feature.PREVIOUS_TRACK, + // Feature.SET_VIDEO_SURFACE + }) + public @interface MSFeature {} + + /** Flags for supported media session features. */ + public static class Feature { + public static final long NONE = 0; + + /** Playback supported. */ + public static final long PLAY = 1 << 0; + + /** Pausing supported. */ + public static final long PAUSE = 1 << 1; + + /** Stopping supported. */ + public static final long STOP = 1 << 2; + + /** Absolute seeking supported. */ + public static final long SEEK_TO = 1 << 3; + + /** Relative seeking supported (forward). */ + public static final long SEEK_FORWARD = 1 << 4; + + /** Relative seeking supported (backward). */ + public static final long SEEK_BACKWARD = 1 << 5; + + /** Skipping advertisements supported. */ + public static final long SKIP_AD = 1 << 6; + + /** Next track selection supported. */ + public static final long NEXT_TRACK = 1 << 7; + + /** Previous track selection supported. */ + public static final long PREVIOUS_TRACK = 1 << 8; + + /** Focusing supported. */ + public static final long FOCUS = 1 << 9; + + // /** + // * Custom video surface supported. + // */ + // public static final long SET_VIDEO_SURFACE = 1 << 10; + + /* package */ static long fromBundle(final GeckoBundle bundle) { + // Sync with MediaController.webidl. + final long features = + NONE + | (bundle.getBoolean("play") ? PLAY : NONE) + | (bundle.getBoolean("pause") ? PAUSE : NONE) + | (bundle.getBoolean("stop") ? STOP : NONE) + | (bundle.getBoolean("seekto") ? SEEK_TO : NONE) + | (bundle.getBoolean("seekforward") ? SEEK_FORWARD : NONE) + | (bundle.getBoolean("seekbackward") ? SEEK_BACKWARD : NONE) + | (bundle.getBoolean("nexttrack") ? NEXT_TRACK : NONE) + | (bundle.getBoolean("previoustrack") ? PREVIOUS_TRACK : NONE) + | (bundle.getBoolean("skipad") ? SKIP_AD : NONE) + | (bundle.getBoolean("focus") ? FOCUS : NONE); + return features; + } + } + + private static final String ACTIVATED_EVENT = "GeckoView:MediaSession:Activated"; + private static final String DEACTIVATED_EVENT = "GeckoView:MediaSession:Deactivated"; + private static final String METADATA_EVENT = "GeckoView:MediaSession:Metadata"; + private static final String POSITION_STATE_EVENT = "GeckoView:MediaSession:PositionState"; + private static final String FEATURES_EVENT = "GeckoView:MediaSession:Features"; + private static final String FULLSCREEN_EVENT = "GeckoView:MediaSession:Fullscreen"; + private static final String PLAYBACK_NONE_EVENT = "GeckoView:MediaSession:Playback:None"; + private static final String PLAYBACK_PAUSED_EVENT = "GeckoView:MediaSession:Playback:Paused"; + private static final String PLAYBACK_PLAYING_EVENT = "GeckoView:MediaSession:Playback:Playing"; + + private static final String PLAY_EVENT = "GeckoView:MediaSession:Play"; + private static final String PAUSE_EVENT = "GeckoView:MediaSession:Pause"; + private static final String STOP_EVENT = "GeckoView:MediaSession:Stop"; + private static final String NEXT_TRACK_EVENT = "GeckoView:MediaSession:NextTrack"; + private static final String PREV_TRACK_EVENT = "GeckoView:MediaSession:PrevTrack"; + private static final String SEEK_FORWARD_EVENT = "GeckoView:MediaSession:SeekForward"; + private static final String SEEK_BACKWARD_EVENT = "GeckoView:MediaSession:SeekBackward"; + private static final String SKIP_AD_EVENT = "GeckoView:MediaSession:SkipAd"; + private static final String SEEK_TO_EVENT = "GeckoView:MediaSession:SeekTo"; + private static final String MUTE_AUDIO_EVENT = "GeckoView:MediaSession:MuteAudio"; + + /* package */ static class Handler extends GeckoSessionHandler<MediaSession.Delegate> { + + private final GeckoSession mSession; + private final MediaSession mMediaSession; + + public Handler(final GeckoSession session) { + super( + "GeckoViewMediaControl", + session, + new String[] { + ACTIVATED_EVENT, + DEACTIVATED_EVENT, + METADATA_EVENT, + FULLSCREEN_EVENT, + POSITION_STATE_EVENT, + PLAYBACK_NONE_EVENT, + PLAYBACK_PAUSED_EVENT, + PLAYBACK_PLAYING_EVENT, + FEATURES_EVENT, + }); + mSession = session; + mMediaSession = new MediaSession(session); + } + + @Override + public void handleMessage( + final Delegate delegate, + final String event, + final GeckoBundle message, + final EventCallback callback) { + if (DEBUG) { + Log.d(LOGTAG, "handleMessage " + event); + } + + if (ACTIVATED_EVENT.equals(event)) { + mMediaSession.setActive(true); + delegate.onActivated(mSession, mMediaSession); + } else if (DEACTIVATED_EVENT.equals(event)) { + mMediaSession.setActive(false); + delegate.onDeactivated(mSession, mMediaSession); + } else if (METADATA_EVENT.equals(event)) { + final Metadata meta = Metadata.fromBundle(message.getBundle("metadata")); + delegate.onMetadata(mSession, mMediaSession, meta); + } else if (POSITION_STATE_EVENT.equals(event)) { + final PositionState state = PositionState.fromBundle(message.getBundle("state")); + delegate.onPositionState(mSession, mMediaSession, state); + } else if (PLAYBACK_NONE_EVENT.equals(event)) { + delegate.onStop(mSession, mMediaSession); + } else if (PLAYBACK_PAUSED_EVENT.equals(event)) { + delegate.onPause(mSession, mMediaSession); + } else if (PLAYBACK_PLAYING_EVENT.equals(event)) { + delegate.onPlay(mSession, mMediaSession); + } else if (FEATURES_EVENT.equals(event)) { + final long features = Feature.fromBundle(message.getBundle("features")); + delegate.onFeatures(mSession, mMediaSession, features); + } else if (FULLSCREEN_EVENT.equals(event) && mMediaSession.isActive()) { + final boolean enabled = message.getBoolean("enabled"); + final ElementMetadata meta = ElementMetadata.fromBundle(message.getBundle("metadata")); + delegate.onFullscreen(mSession, mMediaSession, enabled, meta); + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java new file mode 100644 index 0000000000..e2a4c236b5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OrientationController.java @@ -0,0 +1,60 @@ +/* -*- 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.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.util.ThreadUtils; + +public class OrientationController { + private OrientationDelegate mDelegate; + + OrientationController() {} + + /** + * Sets the {@link OrientationDelegate} for this instance. + * + * @param delegate The {@link OrientationDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable OrientationDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Gets the {@link OrientationDelegate} for this instance. + * + * @return delegate The {@link OrientationDelegate} instance. + */ + @UiThread + @Nullable + public OrientationDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + return mDelegate; + } + + /** This delegate will be called whenever an orientation lock is called. */ + @UiThread + public interface OrientationDelegate { + /** + * Called whenever the orientation should be locked. + * + * @param aOrientation The desired orientation such as ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + * @return A {@link GeckoResult} which resolves to a {@link AllowOrDeny} + */ + @Nullable + default GeckoResult<AllowOrDeny> onOrientationLock(@NonNull final int aOrientation) { + return null; + } + + /** Called whenever the orientation should be unlocked. */ + @Nullable + default void onOrientationUnlock() {} + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java new file mode 100644 index 0000000000..248bfa065a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/OverscrollEdgeEffect.java @@ -0,0 +1,244 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.graphics.BlendMode; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.os.Build; +import android.widget.EdgeEffect; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.reflect.Field; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public final class OverscrollEdgeEffect { + // 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; + + /* package */ static final int AXIS_X = 0; + /* package */ static final int AXIS_Y = 1; + + // All four edges of the screen + private final EdgeEffect[] mEdges = new EdgeEffect[4]; + + private GeckoSession mSession; + private Runnable mInvalidationCallback; + private int mWidth; + private int mHeight; + + /* package */ OverscrollEdgeEffect() {} + + private static Field sPaintField; + + private void setBlendMode(final EdgeEffect edgeEffect) { + if (Build.VERSION.SDK_INT < 29) { + // setBlendMode is only supported on SDK_INT >= 29 and above. + + if (sPaintField == null) { + try { + sPaintField = EdgeEffect.class.getDeclaredField("mPaint"); + sPaintField.setAccessible(true); + } catch (final NoSuchFieldException e) { + // Cannot get the field, nothing we can do here + return; + } + } + + try { + final Paint paint = (Paint) sPaintField.get(edgeEffect); + final PorterDuffXfermode mode = new PorterDuffXfermode(PorterDuff.Mode.SRC); + paint.setXfermode(mode); + } catch (final IllegalAccessException ex) { + // Nothing we can do + } + + return; + } + + edgeEffect.setBlendMode(BlendMode.SRC); + } + + /** + * Set the theme to use for overscroll from a given Context. + * + * @param context Context to use for the overscroll theme. + */ + public void setTheme(final @NonNull Context context) { + ThreadUtils.assertOnUiThread(); + + for (int i = 0; i < mEdges.length; i++) { + final EdgeEffect edgeEffect = new EdgeEffect(context); + if (mWidth != 0 || mHeight != 0) { + edgeEffect.setSize(mWidth, mHeight); + } + setBlendMode(edgeEffect); + mEdges[i] = edgeEffect; + } + } + + /* package */ void setSession(final @Nullable GeckoSession session) { + mSession = session; + } + + /** + * Set a Runnable that acts as a callback to invalidate the overscroll effect (for example, as a + * response to user fling for example). The Runnbale should schedule a future call to {@link + * #draw(Canvas)} as a result of the invalidation. + * + * @param runnable Invalidation Runnable. + * @see #getInvalidationCallback() + */ + public void setInvalidationCallback(final @Nullable Runnable runnable) { + ThreadUtils.assertOnUiThread(); + mInvalidationCallback = runnable; + } + + /** + * Get the current invalidatation Runnable. + * + * @return Invalidation Runnable. + * @see #setInvalidationCallback(Runnable) + */ + public @Nullable Runnable getInvalidationCallback() { + ThreadUtils.assertOnUiThread(); + return mInvalidationCallback; + } + + /* package */ 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); + + mWidth = width; + mHeight = height; + } + + private EdgeEffect getEdgeForAxisAndSide(final int 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]; + } + } + } + + /* package */ void setVelocity(final float velocity, final int axis) { + if (velocity == 0.0f) { + if (axis == AXIS_Y) { + mEdges[TOP].onRelease(); + mEdges[BOTTOM].onRelease(); + } else { + mEdges[LEFT].onRelease(); + mEdges[RIGHT].onRelease(); + } + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + return; + } + + 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); + } + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + /* package */ void setDistance(final float distance, final int 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 ? mWidth : mHeight)); + + if (mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + /** + * Draw the overscroll effect on a Canvas. + * + * @param canvas Canvas to draw on. + */ + public void draw(final @NonNull Canvas canvas) { + ThreadUtils.assertOnUiThread(); + + if (mSession == null) { + return; + } + + final Rect pageRect = new Rect(); + mSession.getSurfaceBounds(pageRect); + + // If we're pulling an edge, or fading it out, draw! + boolean invalidate = false; + if (!mEdges[TOP].isFinished()) { + invalidate |= draw(mEdges[TOP], canvas, pageRect.left, pageRect.top, 0); + } + + if (!mEdges[BOTTOM].isFinished()) { + invalidate |= draw(mEdges[BOTTOM], canvas, pageRect.right, pageRect.bottom, 180); + } + + if (!mEdges[LEFT].isFinished()) { + invalidate |= draw(mEdges[LEFT], canvas, pageRect.left, pageRect.bottom, 270); + } + + if (!mEdges[RIGHT].isFinished()) { + invalidate |= draw(mEdges[RIGHT], canvas, pageRect.right, pageRect.top, 90); + } + + // If the edge effect is animating off screen, invalidate. + if (invalidate && mInvalidationCallback != null) { + mInvalidationCallback.run(); + } + } + + 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); + final boolean invalidate = edge.draw(canvas); + canvas.restoreToCount(state); + + return invalidate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java new file mode 100644 index 0000000000..6b2d8c9fc3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PanZoomController.java @@ -0,0 +1,942 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.app.UiModeManager; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Rect; +import android.os.SystemClock; +import android.util.Log; +import android.util.Pair; +import android.view.InputDevice; +import android.view.MotionEvent; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class PanZoomController { + private static final String LOGTAG = "GeckoNPZC"; + private static final int EVENT_SOURCE_SCROLL = 0; + private static final int EVENT_SOURCE_MOTION = 1; + private static final int EVENT_SOURCE_MOUSE = 2; + private static Boolean sTreatMouseAsTouch = null; + + private final GeckoSession mSession; + private final Rect mTempRect = new Rect(); + private boolean mAttached; + private float mPointerScrollFactor = 64.0f; + private long mLastDownTime; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({SCROLL_BEHAVIOR_SMOOTH, SCROLL_BEHAVIOR_AUTO}) + public @interface ScrollBehaviorType {} + + /** Specifies smooth scrolling which animates content to the desired scroll position. */ + public static final int SCROLL_BEHAVIOR_SMOOTH = 0; + /** Specifies auto scrolling which jumps content to the desired scroll position. */ + public static final int SCROLL_BEHAVIOR_AUTO = 1; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + INPUT_RESULT_UNHANDLED, + INPUT_RESULT_HANDLED, + INPUT_RESULT_HANDLED_CONTENT, + INPUT_RESULT_IGNORED + }) + public @interface InputResult {} + + /** + * Specifies that an input event was not handled by the PanZoomController for a panning or zooming + * operation. The event may have been handled by Web content or internally (e.g. text selection). + */ + @WrapForJNI public static final int INPUT_RESULT_UNHANDLED = 0; + + /** + * Specifies that an input event was handled by the PanZoomController for a panning or zooming + * operation, but likely not by any touch event listeners in Web content. + */ + @WrapForJNI public static final int INPUT_RESULT_HANDLED = 1; + + /** + * Specifies that an input event was handled by the PanZoomController and passed on to touch event + * listeners in Web content. + */ + @WrapForJNI public static final int INPUT_RESULT_HANDLED_CONTENT = 2; + + /** + * Specifies that an input event was consumed by a PanZoomController internally and browsers + * should do nothing in response to the event. + */ + @WrapForJNI public static final int INPUT_RESULT_IGNORED = 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + SCROLLABLE_FLAG_NONE, + SCROLLABLE_FLAG_TOP, + SCROLLABLE_FLAG_RIGHT, + SCROLLABLE_FLAG_BOTTOM, + SCROLLABLE_FLAG_LEFT + }) + public @interface ScrollableDirections {} + /** + * Represents which directions can be scrolled in the scroll container where an input event was + * handled. This value is only useful in the case of {@link + * PanZoomController#INPUT_RESULT_HANDLED}. + */ + /* The container cannot be scrolled. */ + @WrapForJNI public static final int SCROLLABLE_FLAG_NONE = 0; + /* The container cannot be scrolled to top */ + @WrapForJNI public static final int SCROLLABLE_FLAG_TOP = 1 << 0; + /* The container cannot be scrolled to right */ + @WrapForJNI public static final int SCROLLABLE_FLAG_RIGHT = 1 << 1; + /* The container cannot be scrolled to bottom */ + @WrapForJNI public static final int SCROLLABLE_FLAG_BOTTOM = 1 << 2; + /* The container cannot be scrolled to left */ + @WrapForJNI public static final int SCROLLABLE_FLAG_LEFT = 1 << 3; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {OVERSCROLL_FLAG_NONE, OVERSCROLL_FLAG_HORIZONTAL, OVERSCROLL_FLAG_VERTICAL}) + public @interface OverscrollDirections {} + /** + * Represents which directions can be over-scrolled in the scroll container where an input event + * was handled. This value is only useful in the case of {@link + * PanZoomController#INPUT_RESULT_HANDLED}. + */ + /* the container cannot be over-scrolled. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_NONE = 0; + /* the container can be over-scrolled horizontally. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_HORIZONTAL = 1 << 0; + /* the container can be over-scrolled vertically. */ + @WrapForJNI public static final int OVERSCROLL_FLAG_VERTICAL = 1 << 1; + + /** + * Represents how a {@link MotionEvent} was handled in Gecko. This value can be used by browser + * apps to implement features like pull-to-refresh. Failing to account this value might break some + * websites expectations about touch events. + * + * <p>For example, a {@link PanZoomController.InputResultDetail#handledResult} value of {@link + * PanZoomController#INPUT_RESULT_HANDLED} and {@link + * PanZoomController.InputResultDetail#overscrollDirections} of {@link + * PanZoomController#OVERSCROLL_FLAG_NONE} indicates that the event was consumed for a panning or + * zooming operation and that the website does not expect the browser to react to the touch event + * (say, by triggering the pull-to-refresh feature) even though the scroll container reached to + * the edge. + */ + @WrapForJNI + public static class InputResultDetail { + protected InputResultDetail( + final @InputResult int handledResult, + final @ScrollableDirections int scrollableDirections, + final @OverscrollDirections int overscrollDirections) { + mHandledResult = handledResult; + mScrollableDirections = scrollableDirections; + mOverscrollDirections = overscrollDirections; + } + + /** + * @return One of the {@link #INPUT_RESULT_UNHANDLED INPUT_RESULT_*} indicating how the event + * was handled. + */ + @AnyThread + public @InputResult int handledResult() { + return mHandledResult; + } + /** + * @return an OR-ed value of {@link #SCROLLABLE_FLAG_NONE SCROLLABLE_FLAG_*} indicating which + * directions can be scrollable. + */ + @AnyThread + public @ScrollableDirections int scrollableDirections() { + return mScrollableDirections; + } + /** + * @return an OR-ed value of {@link #OVERSCROLL_FLAG_NONE OVERSCROLL_FLAG_*} indicating which + * directions can be over-scrollable. + */ + @AnyThread + public @OverscrollDirections int overscrollDirections() { + return mOverscrollDirections; + } + + private final @InputResult int mHandledResult; + private final @ScrollableDirections int mScrollableDirections; + private final @OverscrollDirections int mOverscrollDirections; + } + + private SynthesizedEventState mPointerState; + + private ArrayList<Pair<Integer, MotionEvent>> mQueuedEvents; + + private boolean mSynthesizedEvent = false; + + @WrapForJNI + private static class MotionEventData { + public final int action; + public final int actionIndex; + public final long time; + public final int metaState; + public final int pointerId[]; + public final int historySize; + public final long historicalTime[]; + public final float historicalX[]; + public final float historicalY[]; + public final float historicalOrientation[]; + public final float historicalPressure[]; + public final float historicalToolMajor[]; + public final float historicalToolMinor[]; + public final float x[]; + public final float y[]; + public final float orientation[]; + public final float pressure[]; + public final float toolMajor[]; + public final float toolMinor[]; + + public MotionEventData(final MotionEvent event) { + final int count = event.getPointerCount(); + action = event.getActionMasked(); + actionIndex = event.getActionIndex(); + time = event.getEventTime(); + metaState = event.getMetaState(); + historySize = event.getHistorySize(); + historicalTime = new long[historySize]; + historicalX = new float[historySize * count]; + historicalY = new float[historySize * count]; + historicalOrientation = new float[historySize * count]; + historicalPressure = new float[historySize * count]; + historicalToolMajor = new float[historySize * count]; + historicalToolMinor = new float[historySize * count]; + pointerId = new int[count]; + x = new float[count]; + y = new float[count]; + orientation = new float[count]; + pressure = new float[count]; + toolMajor = new float[count]; + toolMinor = new float[count]; + + for (int historyIndex = 0; historyIndex < historySize; historyIndex++) { + historicalTime[historyIndex] = event.getHistoricalEventTime(historyIndex); + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + for (int i = 0; i < count; i++) { + pointerId[i] = event.getPointerId(i); + + for (int historyIndex = 0; historyIndex < historySize; historyIndex++) { + event.getHistoricalPointerCoords(i, historyIndex, coords); + + final int historicalI = historyIndex * count + i; + historicalX[historicalI] = coords.x; + historicalY[historicalI] = coords.y; + + historicalOrientation[historicalI] = coords.orientation; + historicalPressure[historicalI] = coords.pressure; + + // If we are converting to CSS pixels, we should adjust the radii as well. + historicalToolMajor[historicalI] = coords.toolMajor; + historicalToolMinor[historicalI] = coords.toolMinor; + } + + 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; + } + } + } + + /* package */ final class NativeProvider extends JNIObject { + @Override // JNIObject + protected void disposeNative() { + // Disposal happens in native code. + throw new UnsupportedOperationException(); + } + + @WrapForJNI(calledFrom = "ui") + private native void handleMotionEvent( + MotionEventData eventData, + float screenX, + float screenY, + GeckoResult<InputResultDetail> result); + + @WrapForJNI(calledFrom = "ui") + private native @InputResult int handleScrollEvent( + long time, int metaState, float x, float y, float hScroll, float vScroll); + + @WrapForJNI(calledFrom = "ui") + private native @InputResult int handleMouseEvent( + int action, long time, int metaState, float x, float y, int buttons); + + @WrapForJNI(stubName = "SetIsLongpressEnabled") // Called from test thread. + private native void nativeSetIsLongpressEnabled(boolean isLongpressEnabled); + + @WrapForJNI(calledFrom = "ui") + private void synthesizeNativeTouchPoint( + final int pointerId, + final int eventType, + final int clientX, + final int clientY, + final double pressure, + final int orientation) { + if (pointerId == PointerInfo.RESERVED_MOUSE_POINTER_ID) { + throw new IllegalArgumentException("Pointer ID reserved for mouse"); + } + synthesizeNativePointer( + InputDevice.SOURCE_TOUCHSCREEN, + pointerId, + eventType, + clientX, + clientY, + pressure, + orientation, + 0); + } + + @WrapForJNI(calledFrom = "ui") + private void synthesizeNativeMouseEvent( + final int eventType, final int clientX, final int clientY, final int button) { + synthesizeNativePointer( + InputDevice.SOURCE_MOUSE, + PointerInfo.RESERVED_MOUSE_POINTER_ID, + eventType, + clientX, + clientY, + 0, + 0, + button); + } + + @WrapForJNI(calledFrom = "ui") + private void setAttached(final boolean attached) { + if (attached) { + mAttached = true; + flushEventQueue(); + } else if (mAttached) { + mAttached = false; + enableEventQueue(); + } + } + } + + /* package */ final NativeProvider mNative = new NativeProvider(); + + private void handleMotionEvent(final MotionEvent event) { + handleMotionEvent(event, null); + } + + private void handleMotionEvent( + final MotionEvent event, final GeckoResult<InputResultDetail> result) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOTION, event)); + if (result != null) { + result.complete( + new InputResultDetail( + INPUT_RESULT_HANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + return; + } + + final int action = event.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + mLastDownTime = event.getDownTime(); + } else if (mLastDownTime != event.getDownTime()) { + if (result != null) { + result.complete( + new InputResultDetail( + INPUT_RESULT_UNHANDLED, SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + return; + } + + final float screenX = event.getRawX() - event.getX(); + final float screenY = event.getRawY() - event.getY(); + + // Take this opportunity to update screen origin of session. This gets + // dispatched to the gecko thread, so we also pass the new screen x/y directly to apz. + // If this is a synthesized touch, the screen offset is bogus so ignore it. + if (!mSynthesizedEvent) { + mSession.onScreenOriginChanged((int) screenX, (int) screenY); + } + + final MotionEventData data = new MotionEventData(event); + mNative.handleMotionEvent(data, screenX, screenY, result); + } + + private @InputResult int handleScrollEvent(final MotionEvent event) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_SCROLL, event)); + return INPUT_RESULT_HANDLED; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return INPUT_RESULT_UNHANDLED; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + + // Translate surface origin to client origin for scroll events. + mSession.getSurfaceBounds(mTempRect); + final float x = coords.x - mTempRect.left; + final float y = coords.y - mTempRect.top; + + final float hScroll = event.getAxisValue(MotionEvent.AXIS_HSCROLL) * mPointerScrollFactor; + final float vScroll = event.getAxisValue(MotionEvent.AXIS_VSCROLL) * mPointerScrollFactor; + + return mNative.handleScrollEvent( + event.getEventTime(), event.getMetaState(), x, y, hScroll, vScroll); + } + + private @InputResult int handleMouseEvent(final MotionEvent event) { + if (!mAttached) { + mQueuedEvents.add(new Pair<>(EVENT_SOURCE_MOUSE, event)); + return INPUT_RESULT_UNHANDLED; + } + + final int count = event.getPointerCount(); + + if (count <= 0) { + return INPUT_RESULT_UNHANDLED; + } + + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + event.getPointerCoords(0, coords); + + // Translate surface origin to client origin for mouse events. + mSession.getSurfaceBounds(mTempRect); + final float x = coords.x - mTempRect.left; + final float y = coords.y - mTempRect.top; + + return mNative.handleMouseEvent( + event.getActionMasked(), + event.getEventTime(), + event.getMetaState(), + x, + y, + event.getButtonState()); + } + + protected PanZoomController(final GeckoSession session) { + mSession = session; + enableEventQueue(); + } + + private boolean treatMouseAsTouch() { + if (sTreatMouseAsTouch == null) { + final Context c = GeckoAppShell.getApplicationContext(); + if (c == null) { + // This might happen if the GeckoRuntime has not been initialized yet. + return false; + } + final UiModeManager m = (UiModeManager) c.getSystemService(Context.UI_MODE_SERVICE); + // on TV devices, treat mouse as touch. everywhere else, don't + sTreatMouseAsTouch = (m.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION); + } + + return sTreatMouseAsTouch; + } + + /** + * Set the current scroll factor. The scroll factor is the maximum scroll amount that one scroll + * event may generate, in device pixels. + * + * @param factor Scroll factor. + */ + public void setScrollFactor(final float factor) { + ThreadUtils.assertOnUiThread(); + mPointerScrollFactor = factor; + } + + /** + * Get the current scroll factor. + * + * @return Scroll factor. + */ + public float getScrollFactor() { + ThreadUtils.assertOnUiThread(); + return mPointerScrollFactor; + } + + /** + * This is a workaround for touch pad on Android app by Chrome OS. Android app on Chrome OS fires + * weird motion event by two finger scroll. See https://crbug.com/704051 + */ + private boolean mayTouchpadScroll(final @NonNull MotionEvent event) { + final int action = event.getActionMasked(); + return event.getButtonState() == 0 + && (action == MotionEvent.ACTION_DOWN + || (mLastDownTime == event.getDownTime() + && (action == MotionEvent.ACTION_MOVE + || action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL))); + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather + * than as "mouse". Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onTouchEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!treatMouseAsTouch() + && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE + && !mayTouchpadScroll(event)) { + handleMouseEvent(event); + return; + } + handleMotionEvent(event); + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "touch" rather + * than as "mouse". Pointer coordinates should be relative to the display surface. + * + * <p>NOTE: It is highly recommended to only call this with ACTION_DOWN or in otherwise limited + * capacity. Returning a GeckoResult for every touch event will generate a lot of allocations and + * unnecessary GC pressure. Instead, prefer to call {@link #onTouchEvent(MotionEvent)}. + * + * @param event MotionEvent to process. + * @return A GeckoResult resolving to {@link PanZoomController.InputResultDetail}). + */ + public @NonNull GeckoResult<InputResultDetail> onTouchEventForDetailResult( + final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!treatMouseAsTouch() + && event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE + && !mayTouchpadScroll(event)) { + return GeckoResult.fromValue( + new InputResultDetail( + handleMouseEvent(event), SCROLLABLE_FLAG_NONE, OVERSCROLL_FLAG_NONE)); + } + + final GeckoResult<InputResultDetail> result = new GeckoResult<>(); + handleMotionEvent(event, result); + return result; + } + + /** + * Process a touch event through the pan-zoom controller. Treat any mouse events as "mouse" rather + * than as "touch". Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onMouseEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (event.getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE) { + return; + } + handleMotionEvent(event); + } + + @Override + protected void finalize() throws Throwable { + mNative.setAttached(false); + } + + /** + * Process a non-touch motion event through the pan-zoom controller. Currently, hover and scroll + * events are supported. Pointer coordinates should be relative to the display surface. + * + * @param event MotionEvent to process. + */ + public void onMotionEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + final int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_SCROLL) { + if (event.getDownTime() >= mLastDownTime) { + mLastDownTime = event.getDownTime(); + } else if ((InputDevice.getDevice(event.getDeviceId()) != null) + && (InputDevice.getDevice(event.getDeviceId()).getSources() & InputDevice.SOURCE_TOUCHPAD) + == InputDevice.SOURCE_TOUCHPAD) { + return; + } + handleScrollEvent(event); + } else if ((action == MotionEvent.ACTION_HOVER_MOVE) + || (action == MotionEvent.ACTION_HOVER_ENTER) + || (action == MotionEvent.ACTION_HOVER_EXIT)) { + handleMouseEvent(event); + } + } + + private void enableEventQueue() { + if (mQueuedEvents != null) { + throw new IllegalStateException("Already have an event queue"); + } + mQueuedEvents = new ArrayList<>(); + } + + private void flushEventQueue() { + if (mQueuedEvents == null) { + return; + } + + final ArrayList<Pair<Integer, MotionEvent>> events = mQueuedEvents; + mQueuedEvents = null; + for (final Pair<Integer, MotionEvent> pair : events) { + switch (pair.first) { + case EVENT_SOURCE_MOTION: + handleMotionEvent(pair.second); + break; + case EVENT_SOURCE_SCROLL: + handleScrollEvent(pair.second); + break; + case EVENT_SOURCE_MOUSE: + handleMouseEvent(pair.second); + break; + } + } + } + + /** + * Set whether Gecko should generate long-press events. + * + * @param isLongpressEnabled True if Gecko should generate long-press events. + */ + public void setIsLongpressEnabled(final boolean isLongpressEnabled) { + ThreadUtils.assertOnUiThread(); + + if (mAttached) { + mNative.nativeSetIsLongpressEnabled(isLongpressEnabled); + } + } + + private static 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 surfaceX; + public int surfaceY; + public double pressure; + public int orientation; + public int buttonState; + + public MotionEvent.PointerCoords getCoords() { + final MotionEvent.PointerCoords coords = new MotionEvent.PointerCoords(); + coords.orientation = orientation; + coords.pressure = (float) pressure; + coords.x = surfaceX; + coords.y = surfaceY; + return coords; + } + } + + private static class SynthesizedEventState { + public final ArrayList<PointerInfo> pointers; + public long downTime; + + SynthesizedEventState() { + pointers = new ArrayList<PointerInfo>(); + } + + int getPointerIndex(final int pointerId) { + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).pointerId == pointerId) { + return i; + } + } + return -1; + } + + int addPointer(final int pointerId, final int source) { + final PointerInfo info = new PointerInfo(); + info.pointerId = pointerId; + info.source = source; + pointers.add(info); + return pointers.size() - 1; + } + + int getPointerCount(final int source) { + int count = 0; + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + count++; + } + } + return count; + } + + int getPointerButtonState(final int source) { + for (int i = 0; i < pointers.size(); i++) { + if (pointers.get(i).source == source) { + return pointers.get(i).buttonState; + } + } + return 0; + } + + MotionEvent.PointerProperties[] getPointerProperties(final int source) { + final 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) { + final 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(final int source) { + final 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( + final int source, + final int pointerId, + final int originalEventType, + final int clientX, + final int clientY, + final double pressure, + final int orientation, + final int button) { + if (mPointerState == null) { + mPointerState = new SynthesizedEventState(); + } + + // Find the pointer if it already exists + int pointerIndex = mPointerState.getPointerIndex(pointerId); + + // Event-specific handling + int eventType = originalEventType; + switch (originalEventType) { + case MotionEvent.ACTION_POINTER_UP: + if (pointerIndex < 0) { + Log.w(LOGTAG, "Pointer-up for invalid pointer"); + return; + } + if (mPointerState.pointers.size() == 1) { + // Last pointer is going up + eventType = MotionEvent.ACTION_UP; + } + break; + case MotionEvent.ACTION_CANCEL: + if (pointerIndex < 0) { + Log.w(LOGTAG, "Pointer-cancel for invalid pointer"); + 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; + } + + // Translate client origin to surface origin. + mSession.getSurfaceBounds(mTempRect); + final int surfaceX = clientX + mTempRect.left; + final int surfaceY = clientY + mTempRect.top; + + // Update the pointer with the new info + final PointerInfo info = mPointerState.pointers.get(pointerIndex); + info.surfaceX = surfaceX; + info.surfaceY = surfaceY; + info.pressure = pressure; + info.orientation = orientation; + if (source == InputDevice.SOURCE_MOUSE) { + if (eventType == MotionEvent.ACTION_DOWN || eventType == MotionEvent.ACTION_MOVE) { + info.buttonState |= button; + } else if (eventType == MotionEvent.ACTION_UP) { + info.buttonState &= button; + } + } + + // Dispatch the event + int action = 0; + if (eventType == MotionEvent.ACTION_POINTER_DOWN + || eventType == MotionEvent.ACTION_POINTER_UP) { + // for pointer-down and pointer-up events we need to add the + // index of the relevant pointer. + action = (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT); + action &= MotionEvent.ACTION_POINTER_INDEX_MASK; + } + action |= (eventType & MotionEvent.ACTION_MASK); + 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*/ mPointerState.getPointerButtonState(source), + /*xPrecision*/ 0, + /*yPrecision*/ 0, + /*deviceId*/ 0, + /*edgeFlags*/ 0, + /*source*/ source, + /*flags*/ 0); + + mSynthesizedEvent = true; + onTouchEvent(event); + mSynthesizedEvent = false; + + // 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); + } + } + + /** + * Scroll the document body by an offset from the current scroll position. Uses {@link + * #SCROLL_BEHAVIOR_SMOOTH}. + * + * @param width {@link ScreenLength} offset to scroll along X axis. + * @param height {@link ScreenLength} offset to scroll along Y axis. + */ + @UiThread + public void scrollBy(final @NonNull ScreenLength width, final @NonNull ScreenLength height) { + scrollBy(width, height, SCROLL_BEHAVIOR_SMOOTH); + } + + /** + * Scroll the document body by an offset from the current scroll position. + * + * @param width {@link ScreenLength} offset to scroll along X axis. + * @param height {@link ScreenLength} offset to scroll along Y axis. + * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link + * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content. + */ + @UiThread + public void scrollBy( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = buildScrollMessage(width, height, behavior); + mSession.getEventDispatcher().dispatch("GeckoView:ScrollBy", msg); + } + + /** + * Scroll the document body to an absolute position. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. + * + * @param width {@link ScreenLength} position to scroll along X axis. + * @param height {@link ScreenLength} position to scroll along Y axis. + */ + @UiThread + public void scrollTo(final @NonNull ScreenLength width, final @NonNull ScreenLength height) { + scrollTo(width, height, SCROLL_BEHAVIOR_SMOOTH); + } + + /** + * Scroll the document body to an absolute position. + * + * @param width {@link ScreenLength} position to scroll along X axis. + * @param height {@link ScreenLength} position to scroll along Y axis. + * @param behavior ScrollBehaviorType One of {@link #SCROLL_BEHAVIOR_SMOOTH}, {@link + * #SCROLL_BEHAVIOR_AUTO}, that specifies how to scroll the content. + */ + @UiThread + public void scrollTo( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = buildScrollMessage(width, height, behavior); + mSession.getEventDispatcher().dispatch("GeckoView:ScrollTo", msg); + } + + /** Scroll to the top left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */ + @UiThread + public void scrollToTop() { + scrollTo(ScreenLength.zero(), ScreenLength.top(), SCROLL_BEHAVIOR_SMOOTH); + } + + /** Scroll to the bottom left corner of the screen. Uses {@link #SCROLL_BEHAVIOR_SMOOTH}. */ + @UiThread + public void scrollToBottom() { + scrollTo(ScreenLength.zero(), ScreenLength.bottom(), SCROLL_BEHAVIOR_SMOOTH); + } + + private GeckoBundle buildScrollMessage( + final @NonNull ScreenLength width, + final @NonNull ScreenLength height, + final @ScrollBehaviorType int behavior) { + final GeckoBundle msg = new GeckoBundle(); + msg.putDouble("widthValue", width.getValue()); + msg.putInt("widthType", width.getType()); + msg.putDouble("heightValue", height.getValue()); + msg.putInt("heightType", height.getType()); + msg.putInt("behavior", behavior); + return msg; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java new file mode 100644 index 0000000000..7feb7d88ae --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ParcelableUtils.java @@ -0,0 +1,19 @@ +/* -*- 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.geckoview; + +import android.os.Parcel; + +class ParcelableUtils { + public static void writeBoolean(final Parcel out, final boolean val) { + out.writeByte((byte) (val ? 1 : 0)); + } + + public static boolean readBoolean(final Parcel source) { + return source.readByte() == 1; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java new file mode 100644 index 0000000000..9e655c5eb7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ProfilerController.java @@ -0,0 +1,182 @@ +/* -*- 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.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.GeckoJavaSampler; + +/** + * ProfilerController is used to manage GeckoProfiler related features. + * + * <p>If you want to add a profiler marker to mark a point in time (without a duration) you can + * directly use <code>profilerController.addMarker("marker name")</code>. Or if you want to provide + * more information, you can use <code> + * profilerController.addMarker("marker name", "extra information")</code> If you want to add a + * profiler marker with a duration (with start and end time) you can use it like this: <code> + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * profilerController.addMarker("name", startTime); + * </code> Or you can capture start and end time in somewhere, then add the marker in somewhere + * else: <code> + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure (or end time can be collected in a callback)... + * Double endTime = profilerController.getProfilerTime(); + * + * ...somewhere else in the codebase... + * profilerController.addMarker("name", startTime, endTime); + * </code> Here's an <code>addMarker</code> example with all the possible parameters: <code> + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * Double endTime = profilerController.getProfilerTime(); + * + * ...somewhere else in the codebase... + * profilerController.addMarker("name", startTime, endTime, "extra information"); + * </code> <code>isProfilerActive</code> method is handy when you want to get more information to + * add inside the marker, but you think it's going to be computationally heavy (and useless) when + * profiler is not running: + * + * <pre> + * <code> + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * if (profilerController.isProfilerActive()) { + * String info = aFunctionYouDoNotWantToCallWhenProfilerIsNotActive(); + * profilerController.addMarker("name", startTime, info); + * } + * </code> + * </pre> + * + * FIXME(bug 1618560): Currently only works in the main thread. + */ +@UiThread +public class ProfilerController { + private static final String LOGTAG = "ProfilerController"; + + /** + * Returns true if profiler is active and it's allowed the add markers. It's useful when it's + * computationally heavy to get startTime or the additional text for the marker. That code can be + * wrapped with isProfilerActive if check to reduce the overhead of it. + * + * @return true if profiler is active and safe to add a new marker. + */ + public boolean isProfilerActive() { + return GeckoJavaSampler.isProfilerActive(); + } + + /** + * Get the profiler time to be able to mark the start of the marker events. can be used like this: + * <code> + * Double startTime = profilerController.getProfilerTime(); + * ...some code you want to measure... + * profilerController.addMarker("name", startTime); + * </code> + * + * @return profiler time as double or null if the profiler is not active. + */ + public @Nullable Double getProfilerTime() { + return GeckoJavaSampler.tryToGetProfilerTime(); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. No-op if profiler is not + * active. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aEndTime End time as Double. If it's null, this function implicitly gets the end time. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final Double aEndTime, + @Nullable final String aText) { + GeckoJavaSampler.addMarker(aMarkerName, aStartTime, aEndTime, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker( + @NonNull final String aMarkerName, + @Nullable final Double aStartTime, + @Nullable final String aText) { + GeckoJavaSampler.addMarker(aMarkerName, aStartTime, null, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. End time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aStartTime Start time as Double. It can be null if you want to mark a point of time. + */ + public void addMarker(@NonNull final String aMarkerName, @Nullable final Double aStartTime) { + addMarker(aMarkerName, aStartTime, null, null); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + * @param aText An optional string field for more information about the marker. + */ + public void addMarker(@NonNull final String aMarkerName, @Nullable final String aText) { + addMarker(aMarkerName, null, null, aText); + } + + /** + * Add a profiler marker to Gecko Profiler with the given arguments. Time will be added + * automatically with the current profiler time when the function is called. No-op if profiler is + * not active. This is an overload of {@link #addMarker(String, Double, Double, String)} for + * convenience. + * + * @param aMarkerName Name of the event as a string. + */ + public void addMarker(@NonNull final String aMarkerName) { + addMarker(aMarkerName, null, null, null); + } + + /** + * Start the Gecko profiler with the given settings. This is used by embedders which want to + * control the profiler from the embedding app. This allows them to provide an easier access point + * to profiling, as an alternative to the traditional way of using a desktop Firefox instance + * connected via USB + adb. + * + * @param aFilters The list of threads to profile, as an array of string of thread names filters. + * Each filter is used as a case-insensitive substring match against the actual thread names. + * @param aFeaturesArr The list of profiler features to enable for profiling, as a string array. + */ + public void startProfiler( + @NonNull final String[] aFilters, @NonNull final String[] aFeaturesArr) { + GeckoJavaSampler.startProfiler(aFilters, aFeaturesArr); + } + + /** + * Stop the profiler and capture the recorded profile. This method is asynchronous. + * + * @return GeckoResult for the captured profile. The profile is returned as a byte[] buffer + * containing a gzip-compressed payload (with gzip header) of the profile JSON. + */ + public @NonNull GeckoResult<byte[]> stopProfiler() { + return GeckoJavaSampler.stopProfiler(); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java new file mode 100644 index 0000000000..72a07c218e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/PromptController.java @@ -0,0 +1,646 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Log; +import java.util.HashMap; +import java.util.Map; +import org.json.JSONException; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.Autocomplete.AddressSaveOption; +import org.mozilla.geckoview.Autocomplete.AddressSelectOption; +import org.mozilla.geckoview.Autocomplete.CreditCardSaveOption; +import org.mozilla.geckoview.Autocomplete.CreditCardSelectOption; +import org.mozilla.geckoview.Autocomplete.LoginSaveOption; +import org.mozilla.geckoview.Autocomplete.LoginSelectOption; +import org.mozilla.geckoview.GeckoSession.PromptDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AlertPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AuthPrompt.AuthOptions; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt.Observer; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.BeforeUnloadPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ButtonPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ChoicePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.ColorPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.DateTimePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PopupPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptInstanceDelegate; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.PromptResponse; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.RepostConfirmPrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt; +import org.mozilla.geckoview.GeckoSession.PromptDelegate.TextPrompt; + +/* package */ class PromptController { + private static final String LOGTAG = "Prompts"; + + private static class PromptStorage implements BasePrompt.Observer { + private final Map<String, BasePrompt> mPrompts = new HashMap<>(); + + public void addPrompt(final String id, final BasePrompt prompt) { + if (mPrompts.containsKey(id)) { + Log.e(LOGTAG, "Prompt already exists! id=" + id); + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Prompt already exists! id=" + id); + } + } + mPrompts.put(id, prompt); + } + + @Override + public void onPromptCompleted(final BasePrompt prompt) { + // No need to notify this delegate since the prompt has been completed already. + mPrompts.remove(prompt.id); + } + + public void dismiss(final String id) { + final BasePrompt prompt = mPrompts.get(id); + if (prompt == null) { + return; + } + final PromptInstanceDelegate delegate = prompt.getDelegate(); + if (delegate != null) { + delegate.onPromptDismiss(prompt); + } + mPrompts.remove(prompt.id); + } + + public boolean contains(final String id) { + return mPrompts.containsKey(id); + } + + public void update(final BasePrompt prompt) { + final BasePrompt previousPrompt = mPrompts.get(prompt.id); + if (previousPrompt == null) { + return; + } + final PromptInstanceDelegate delegate = previousPrompt.getDelegate(); + if (delegate == null) { + return; + } + prompt.setDelegate(delegate); + delegate.onPromptUpdate(prompt); + mPrompts.put(prompt.id, prompt); + } + } + + final PromptStorage mStorage = new PromptStorage(); + + public void dismissPrompt(final String id) { + mStorage.dismiss(id); + } + + public void updatePrompt(final GeckoBundle message) { + final String type = message.getString("type"); + final PromptHandler<?> handler = sPromptHandlers.handlerFor(type); + if (handler == null) { + // Invalid prompt message type to update the prompt. + return; + } + final BasePrompt prompt = handler.newPrompt(message, mStorage); + if (prompt == null) { + // Invalid prompt message to update the prompt. + return; + } + if (!mStorage.contains(prompt.id)) { + // Invalid prompt id to update the prompt. Dismissed? + return; + } + + mStorage.update(prompt); + } + + public void handleEvent( + final GeckoSession session, final GeckoBundle message, final EventCallback callback) { + Log.d(LOGTAG, "handleEvent " + message.getString("type")); + final PromptDelegate delegate = session.getPromptDelegate(); + if (delegate == null) { + // Default behavior is same as calling dismiss() on callback. + callback.sendSuccess(null); + return; + } + + final String type = message.getString("type"); + final PromptHandler<?> handler = sPromptHandlers.handlerFor(type); + if (handler == null) { + callback.sendError("Invalid type: " + type); + return; + } + final GeckoResult<PromptResponse> res = getResponse(message, session, delegate, handler); + + if (res == null) { + // Adhere to default behavior if the delegate returns null. + callback.sendSuccess(null); + } else { + res.accept( + value -> value.dispatch(callback), + exception -> callback.sendError("Failed to get prompt response.")); + } + } + + private <PromptType extends BasePrompt> GeckoResult<PromptResponse> getResponse( + final GeckoBundle message, + final GeckoSession session, + final PromptDelegate delegate, + final PromptHandler<PromptType> handler) { + final PromptType prompt = handler.newPrompt(message, mStorage); + if (prompt == null) { + try { + Log.e(LOGTAG, "Invalid prompt: " + message.toJSONObject().toString()); + } catch (final JSONException ex) { + Log.e(LOGTAG, "Invalid prompt, invalid data", ex); + } + + return GeckoResult.fromException(new IllegalArgumentException("Invalid prompt data.")); + } + + mStorage.addPrompt(prompt.id, prompt); + return handler.callDelegate(prompt, session, delegate); + } + + private interface PromptHandler<PromptType extends BasePrompt> { + PromptType newPrompt(GeckoBundle info, Observer observer); + + GeckoResult<PromptResponse> callDelegate( + PromptType prompt, GeckoSession session, PromptDelegate delegate); + } + + private static final class AlertHandler implements PromptHandler<AlertPrompt> { + @Override + public AlertPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new AlertPrompt( + info.getString("id"), info.getString("title"), info.getString("msg"), observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AlertPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onAlertPrompt(session, prompt); + } + } + + private static final class BeforeUnloadHandler implements PromptHandler<BeforeUnloadPrompt> { + @Override + public BeforeUnloadPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new BeforeUnloadPrompt(info.getString("id"), observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final BeforeUnloadPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onBeforeUnloadPrompt(session, prompt); + } + } + + private static final class ButtonHandler implements PromptHandler<ButtonPrompt> { + @Override + public ButtonPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new ButtonPrompt( + info.getString("id"), info.getString("title"), info.getString("msg"), observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final ButtonPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onButtonPrompt(session, prompt); + } + } + + private static final class TextHandler implements PromptHandler<TextPrompt> { + @Override + public TextPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new TextPrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + info.getString("value"), + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final TextPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onTextPrompt(session, prompt); + } + } + + private static final class AuthHandler implements PromptHandler<AuthPrompt> { + @Override + public AuthPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new AuthPrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + new AuthOptions(info.getBundle("options")), + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AuthPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onAuthPrompt(session, prompt); + } + } + + private static final class ChoiceHandler implements PromptHandler<ChoicePrompt> { + @Override + public ChoicePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final int intMode; + final String mode = info.getString("mode"); + if ("menu".equals(mode)) { + intMode = ChoicePrompt.Type.MENU; + } else if ("single".equals(mode)) { + intMode = ChoicePrompt.Type.SINGLE; + } else if ("multiple".equals(mode)) { + intMode = ChoicePrompt.Type.MULTIPLE; + } else { + return null; + } + + final GeckoBundle[] choiceBundles = info.getBundleArray("choices"); + final ChoicePrompt.Choice[] choices; + if (choiceBundles == null || choiceBundles.length == 0) { + choices = new ChoicePrompt.Choice[0]; + } else { + choices = new ChoicePrompt.Choice[choiceBundles.length]; + for (int i = 0; i < choiceBundles.length; i++) { + choices[i] = new ChoicePrompt.Choice(choiceBundles[i]); + } + } + + return new ChoicePrompt( + info.getString("id"), + info.getString("title"), + info.getString("msg"), + intMode, + choices, + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final ChoicePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onChoicePrompt(session, prompt); + } + } + + private static final class ColorHandler implements PromptHandler<ColorPrompt> { + @Override + public ColorPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new ColorPrompt( + info.getString("id"), + info.getString("title"), + info.getString("value"), + info.getStringArray("predefinedValues"), + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final ColorPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onColorPrompt(session, prompt); + } + } + + private static final class DateTimeHandler implements PromptHandler<DateTimePrompt> { + @Override + public DateTimePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final String mode = info.getString("mode"); + final int intMode; + if ("date".equals(mode)) { + intMode = DateTimePrompt.Type.DATE; + } else if ("month".equals(mode)) { + intMode = DateTimePrompt.Type.MONTH; + } else if ("week".equals(mode)) { + intMode = DateTimePrompt.Type.WEEK; + } else if ("time".equals(mode)) { + intMode = DateTimePrompt.Type.TIME; + } else if ("datetime-local".equals(mode)) { + intMode = DateTimePrompt.Type.DATETIME_LOCAL; + } else { + return null; + } + + final String defaultValue = info.getString("value"); + final String minValue = info.getString("min"); + final String maxValue = info.getString("max"); + final String stepValue = info.getString("step"); + return new DateTimePrompt( + info.getString("id"), + info.getString("title"), + intMode, + defaultValue, + minValue, + maxValue, + stepValue, + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final DateTimePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onDateTimePrompt(session, prompt); + } + } + + private static final class FileHandler implements PromptHandler<FilePrompt> { + @Override + public FilePrompt newPrompt(final GeckoBundle info, final Observer observer) { + final String mode = info.getString("mode"); + final int intMode; + if ("single".equals(mode)) { + intMode = FilePrompt.Type.SINGLE; + } else if ("multiple".equals(mode)) { + intMode = FilePrompt.Type.MULTIPLE; + } else { + return null; + } + + final String[] mimeTypes = info.getStringArray("mimeTypes"); + final int capture = info.getInt("capture"); + return new FilePrompt( + info.getString("id"), info.getString("title"), intMode, capture, mimeTypes, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final FilePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onFilePrompt(session, prompt); + } + } + + private static final class PopupHandler implements PromptHandler<PopupPrompt> { + @Override + public PopupPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new PopupPrompt(info.getString("id"), info.getString("targetUri"), observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final PopupPrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onPopupPrompt(session, prompt); + } + } + + private static final class RepostHandler implements PromptHandler<RepostConfirmPrompt> { + @Override + public RepostConfirmPrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new RepostConfirmPrompt(info.getString("id"), observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final RepostConfirmPrompt prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onRepostConfirmPrompt(session, prompt); + } + } + + private static final class ShareHandler implements PromptHandler<SharePrompt> { + @Override + public SharePrompt newPrompt(final GeckoBundle info, final Observer observer) { + return new SharePrompt( + info.getString("id"), + info.getString("title"), + info.getString("text"), + info.getString("uri"), + observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final SharePrompt prompt, final GeckoSession session, final PromptDelegate delegate) { + return delegate.onSharePrompt(session, prompt); + } + } + + private static final class LoginSaveHandler + implements PromptHandler<AutocompleteRequest<LoginSaveOption>> { + @Override + public AutocompleteRequest<LoginSaveOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final int hint = info.getInt("hint"); + final GeckoBundle[] loginBundles = info.getBundleArray("logins"); + + if (loginBundles == null) { + return null; + } + + final Autocomplete.LoginSaveOption[] options = + new Autocomplete.LoginSaveOption[loginBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.LoginSaveOption(new Autocomplete.LoginEntry(loginBundles[i]), hint); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<LoginSaveOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onLoginSave(session, prompt); + } + } + + private static final class CreditCardSaveHandler + implements PromptHandler<AutocompleteRequest<CreditCardSaveOption>> { + @Override + public AutocompleteRequest<CreditCardSaveOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final int hint = info.getInt("hint"); + final GeckoBundle[] creditCardBundles = info.getBundleArray("creditCards"); + + if (creditCardBundles == null) { + return null; + } + + final Autocomplete.CreditCardSaveOption[] options = + new Autocomplete.CreditCardSaveOption[creditCardBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.CreditCardSaveOption( + new Autocomplete.CreditCard(creditCardBundles[i]), hint); + } + + return new PromptDelegate.AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<CreditCardSaveOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onCreditCardSave(session, prompt); + } + } + + private static final class AddressSaveHandler + implements PromptHandler<AutocompleteRequest<AddressSaveOption>> { + @Override + public AutocompleteRequest<AddressSaveOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] addressBundles = info.getBundleArray("addresses"); + + if (addressBundles == null) { + return null; + } + + final Autocomplete.AddressSaveOption[] options = + new Autocomplete.AddressSaveOption[addressBundles.length]; + + final int hint = info.getInt("hint"); + for (int i = 0; i < options.length; ++i) { + options[i] = + new Autocomplete.AddressSaveOption(new Autocomplete.Address(addressBundles[i]), hint); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<AddressSaveOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onAddressSave(session, prompt); + } + } + + private static final class LoginSelectHandler + implements PromptHandler<AutocompleteRequest<LoginSelectOption>> { + @Override + public AutocompleteRequest<LoginSelectOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.LoginSelectOption[] options = + new Autocomplete.LoginSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.LoginSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<LoginSelectOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onLoginSelect(session, prompt); + } + } + + private static final class CreditCardSelectHandler + implements PromptHandler<AutocompleteRequest<CreditCardSelectOption>> { + @Override + public AutocompleteRequest<CreditCardSelectOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.CreditCardSelectOption[] options = + new Autocomplete.CreditCardSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.CreditCardSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<CreditCardSelectOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onCreditCardSelect(session, prompt); + } + } + + private static final class AddressSelectHandler + implements PromptHandler<AutocompleteRequest<AddressSelectOption>> { + @Override + public AutocompleteRequest<AddressSelectOption> newPrompt( + final GeckoBundle info, final Observer observer) { + final GeckoBundle[] optionBundles = info.getBundleArray("options"); + + if (optionBundles == null) { + return null; + } + + final Autocomplete.AddressSelectOption[] options = + new Autocomplete.AddressSelectOption[optionBundles.length]; + + for (int i = 0; i < options.length; ++i) { + options[i] = Autocomplete.AddressSelectOption.fromBundle(optionBundles[i]); + } + + return new AutocompleteRequest<>(info.getString("id"), options, observer); + } + + @Override + public GeckoResult<PromptResponse> callDelegate( + final AutocompleteRequest<AddressSelectOption> prompt, + final GeckoSession session, + final PromptDelegate delegate) { + return delegate.onAddressSelect(session, prompt); + } + } + + private static class PromptHandlers { + final Map<String, PromptHandler<?>> mPromptHandlers = new HashMap<>(); + + public void register(final PromptHandler<?> handler, final String type) { + mPromptHandlers.put(type, handler); + } + + public PromptHandler<?> handlerFor(final String type) { + return mPromptHandlers.get(type); + } + } + + private static final PromptHandlers sPromptHandlers = new PromptHandlers(); + + static { + sPromptHandlers.register(new AlertHandler(), "alert"); + sPromptHandlers.register(new BeforeUnloadHandler(), "beforeUnload"); + sPromptHandlers.register(new ButtonHandler(), "button"); + sPromptHandlers.register(new TextHandler(), "text"); + sPromptHandlers.register(new AuthHandler(), "auth"); + sPromptHandlers.register(new ChoiceHandler(), "choice"); + sPromptHandlers.register(new ColorHandler(), "color"); + sPromptHandlers.register(new DateTimeHandler(), "datetime"); + sPromptHandlers.register(new FileHandler(), "file"); + sPromptHandlers.register(new PopupHandler(), "popup"); + sPromptHandlers.register(new RepostHandler(), "repost"); + sPromptHandlers.register(new ShareHandler(), "share"); + sPromptHandlers.register(new LoginSaveHandler(), "Autocomplete:Save:Login"); + sPromptHandlers.register(new CreditCardSaveHandler(), "Autocomplete:Save:CreditCard"); + sPromptHandlers.register(new AddressSaveHandler(), "Autocomplete:Save:Address"); + sPromptHandlers.register(new LoginSelectHandler(), "Autocomplete:Select:Login"); + sPromptHandlers.register(new CreditCardSelectHandler(), "Autocomplete:Select:CreditCard"); + sPromptHandlers.register(new AddressSelectHandler(), "Autocomplete:Select:Address"); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java new file mode 100644 index 0000000000..299dec95f1 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeSettings.java @@ -0,0 +1,266 @@ +/* -*- 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.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * Base class for (nested) runtime settings. + * + * <p>Handles pref-based settings. Please extend this class when adding nested settings for + * GeckoRuntimeSettings. + */ +public abstract class RuntimeSettings implements Parcelable { + /** + * Base class for (nested) runtime settings builders. + * + * <p>Please extend this class when adding nested settings builders for GeckoRuntimeSettings. + */ + public abstract static class Builder<Settings extends RuntimeSettings> { + private final Settings mSettings; + + @SuppressWarnings("checkstyle:javadocmethod") + public Builder() { + mSettings = newSettings(null); + } + + /** + * Finalize and return the settings. + * + * @return The constructed settings. + */ + @AnyThread + public @NonNull Settings build() { + return newSettings(mSettings); + } + + @AnyThread + protected @NonNull Settings getSettings() { + return mSettings; + } + + /** + * Create a default or copy settings object. + * + * @param settings Settings object to copy, null for default settings. + * @return The constructed settings object. + */ + @AnyThread + protected abstract @NonNull Settings newSettings(final @Nullable Settings settings); + } + + /** Used to handle pref-based settings. */ + /* package */ class Pref<T> { + public final String name; + public final T defaultValue; + private T mValue; + private boolean mIsSet; + + public Pref(@NonNull final String name, final T defaultValue) { + this.name = name; + this.defaultValue = defaultValue; + mValue = defaultValue; + + RuntimeSettings.this.addPref(this); + } + + public void set(final T newValue) { + mValue = newValue; + mIsSet = true; + } + + public void commit(final T newValue) { + if (newValue.equals(mValue)) { + return; + } + set(newValue); + commit(); + } + + public void commit() { + final GeckoRuntime runtime = RuntimeSettings.this.getRuntime(); + if (runtime == null) { + return; + } + final GeckoBundle prefs = new GeckoBundle(1); + addToBundle(prefs); + runtime.setDefaultPrefs(prefs); + } + + public T get() { + return mValue; + } + + public boolean isSet() { + return mIsSet; + } + + public void reset() { + mValue = defaultValue; + mIsSet = false; + } + + private void addToBundle(final GeckoBundle bundle) { + final T value = mIsSet ? mValue : defaultValue; + if (value instanceof String) { + bundle.putString(name, (String) value); + } else if (value instanceof Integer) { + bundle.putInt(name, (Integer) value); + } else if (value instanceof Boolean) { + bundle.putBoolean(name, (Boolean) value); + } else { + throw new UnsupportedOperationException("Unhandled pref type for " + name); + } + } + } + + private RuntimeSettings mParent; + private final ArrayList<RuntimeSettings> mChildren; + private final ArrayList<Pref<?>> mPrefs; + + protected RuntimeSettings() { + this(null /* parent */); + } + + /** + * Create settings object. + * + * @param parent The parent settings, specify in case of nested settings. + */ + protected RuntimeSettings(final @Nullable RuntimeSettings parent) { + mPrefs = new ArrayList<Pref<?>>(); + mChildren = new ArrayList<RuntimeSettings>(); + + setParent(parent); + } + + /** + * Update the prefs based on the provided settings. + * + * @param settings Copy from this settings. + */ + @AnyThread + protected void updatePrefs(final @NonNull RuntimeSettings settings) { + if (mPrefs.size() != settings.mPrefs.size()) { + throw new IllegalArgumentException("Settings must be compatible"); + } + + for (int i = 0; i < mPrefs.size(); ++i) { + if (!mPrefs.get(i).name.equals(settings.mPrefs.get(i).name)) { + throw new IllegalArgumentException("Settings must be compatible"); + } + if (!settings.mPrefs.get(i).isSet()) { + continue; + } + // We know it is safe. + @SuppressWarnings("unchecked") + final Pref<Object> uncheckedPref = (Pref<Object>) mPrefs.get(i); + uncheckedPref.commit(settings.mPrefs.get(i).get()); + } + } + + /* package */ @Nullable + GeckoRuntime getRuntime() { + if (mParent != null) { + return mParent.getRuntime(); + } + return null; + } + + private void setParent(final @Nullable RuntimeSettings parent) { + mParent = parent; + if (mParent != null) { + mParent.addChild(this); + } + } + + private void addChild(final @NonNull RuntimeSettings child) { + mChildren.add(child); + } + + /* pacakge */ void addPref(final Pref<?> pref) { + mPrefs.add(pref); + } + + /** + * Return a mapping of the prefs managed in this settings, including child settings. + * + * @return A key-value mapping of the prefs. + */ + /* package */ @NonNull + Map<String, Object> getPrefsMap() { + final ArrayMap<String, Object> prefs = new ArrayMap<>(); + forAllPrefs(pref -> prefs.put(pref.name, pref.get())); + + return Collections.unmodifiableMap(prefs); + } + + /** + * Iterates through all prefs in this RuntimeSettings instance and in all children, grandchildren, + * etc. + */ + private void forAllPrefs(final GeckoResult.Consumer<Pref<?>> visitor) { + for (final RuntimeSettings child : mChildren) { + child.forAllPrefs(visitor); + } + + for (final Pref<?> pref : mPrefs) { + visitor.accept(pref); + } + } + + /** + * Reset the prefs managed by this settings and its children. + * + * <p>The actual prefs values are set via {@link #getPrefsMap} during initialization and via + * {@link Pref#commit} during runtime for individual prefs. + */ + /* package */ void commitResetPrefs() { + final ArrayList<String> names = new ArrayList<String>(); + forAllPrefs(pref -> names.add(pref.name)); + + final GeckoBundle data = new GeckoBundle(1); + data.putStringArray("names", names); + EventDispatcher.getInstance().dispatch("GeckoView:ResetUserPrefs", data); + } + + @Override // Parcelable + @AnyThread + public int describeContents() { + return 0; + } + + @Override // Parcelable + @AnyThread + public void writeToParcel(final Parcel out, final int flags) { + for (final Pref<?> pref : mPrefs) { + out.writeValue(pref.get()); + } + } + + @AnyThread + // AIDL code may call readFromParcel even though it's not part of Parcelable. + @SuppressWarnings("checkstyle:javadocmethod") + public void readFromParcel(final @NonNull Parcel source) { + for (final Pref<?> pref : mPrefs) { + // We know this is safe. + @SuppressWarnings("unchecked") + final Pref<Object> uncheckedPref = (Pref<Object>) pref; + uncheckedPref.commit(source.readValue(getClass().getClassLoader())); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java new file mode 100644 index 0000000000..1fad0cb17e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/RuntimeTelemetry.java @@ -0,0 +1,171 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; + +/** The telemetry API gives access to telemetry data of the Gecko runtime. */ +public final class RuntimeTelemetry { + protected RuntimeTelemetry() {} + + /** + * The runtime telemetry metric object. + * + * @param <T> type of the underlying metric sample + */ + public static class Metric<T> { + /** The runtime metric name. */ + public final @NonNull String name; + + /** The metric values. */ + public final @NonNull T value; + + /* package */ Metric(final String name, final T value) { + this.name = name; + this.value = value; + } + + @Override + public String toString() { + return "name: " + name + ", value: " + value; + } + + // For testing + protected Metric() { + name = null; + value = null; + } + } + + /** The Histogram telemetry metric object. */ + public static class Histogram extends Metric<long[]> { + /** Whether or not this is a Categorical Histogram. */ + public final boolean isCategorical; + + /* package */ Histogram(final boolean isCategorical, final String name, final long[] value) { + super(name, value); + this.isCategorical = isCategorical; + } + + // For testing + protected Histogram() { + super(null, null); + isCategorical = false; + } + } + + /** + * The runtime telemetry delegate. Implement this if you want to receive runtime (Gecko) telemetry + * and attach it via {@link GeckoRuntimeSettings.Builder#telemetryDelegate}. + */ + public interface Delegate { + /** + * A runtime telemetry histogram metric has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onHistogram(final @NonNull Histogram metric) {} + + /** + * A runtime telemetry boolean scalar has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onBooleanScalar(final @NonNull Metric<Boolean> metric) {} + + /** + * A runtime telemetry long scalar has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onLongScalar(final @NonNull Metric<Long> metric) {} + + /** + * A runtime telemetry string scalar has been received. + * + * @param metric The runtime metric details. + */ + @AnyThread + default void onStringScalar(final @NonNull Metric<String> metric) {} + } + + // The proxy connects to telemetry core and forwards telemetry events + // to the attached delegate. + /* package */ static final class Proxy extends JNIObject { + private final Delegate mDelegate; + + public Proxy(final @NonNull Delegate delegate) { + mDelegate = delegate; + } + + // Attach to current runtime. + // We might have different mechanics of attaching to specific runtimes + // in future, for which case we should split the delegate assignment in + // the setup phase from the attaching. + public void attach() { + if (GeckoThread.isRunning()) { + registerDelegateProxy(this); + } else { + GeckoThread.queueNativeCall(Proxy.class, "registerDelegateProxy", Proxy.class, this); + } + } + + public @NonNull Delegate getDelegate() { + return mDelegate; + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void registerDelegateProxy(Proxy proxy); + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchHistogram( + final boolean isCategorical, final String name, final long[] values) { + if (mDelegate == null) { + // TODO throw? + return; + } + mDelegate.onHistogram(new Histogram(isCategorical, name, values)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchStringScalar(final String name, final String value) { + if (mDelegate == null) { + return; + } + mDelegate.onStringScalar(new Metric<>(name, value)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchBooleanScalar(final String name, final boolean value) { + if (mDelegate == null) { + return; + } + mDelegate.onBooleanScalar(new Metric<>(name, value)); + } + + @WrapForJNI(calledFrom = "gecko") + /* package */ void dispatchLongScalar(final String name, final long value) { + if (mDelegate == null) { + return; + } + mDelegate.onLongScalar(new Metric<>(name, value)); + } + + @Override // JNIObject + protected void disposeNative() { + // We don't hold native references. + throw new UnsupportedOperationException(); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java new file mode 100644 index 0000000000..b508fd13f9 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/ScreenLength.java @@ -0,0 +1,160 @@ +/* License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * ScreenLength is a class that represents a length on the screen using different units. The default + * unit is a pixel. However lengths may be also represented by a dimension of the visual viewport or + * of the full scroll size of the root document. + */ +public class ScreenLength { + @Retention(RetentionPolicy.SOURCE) + @IntDef({PIXEL, VISUAL_VIEWPORT_WIDTH, VISUAL_VIEWPORT_HEIGHT, DOCUMENT_WIDTH, DOCUMENT_HEIGHT}) + public @interface ScreenLengthType {} + + /** Pixel units. */ + public static final int PIXEL = 0; + /** + * Units are in visual viewport width. If the visual viewport is 100 pixels wide, then a value of + * 2.0 would represent a length of 200 pixels. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual + * Viewport</a> + */ + public static final int VISUAL_VIEWPORT_WIDTH = 1; + /** + * Units are in visual viewport height. If the visual viewport is 100 pixels high, then a value of + * 2.0 would represent a length of 200 pixels. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Glossary/Visual_Viewport">MDN Visual + * Viewport</a> + */ + public static final int VISUAL_VIEWPORT_HEIGHT = 2; + /** + * Units represent the entire scrollable documents width. If the document is 1000 pixels wide then + * a value of 1.0 would represent 1000 pixels. + */ + public static final int DOCUMENT_WIDTH = 3; + /** + * Units represent the entire scrollable documents height. If the document is 1000 pixels tall + * then a value of 1.0 would represent 1000 pixels. + */ + public static final int DOCUMENT_HEIGHT = 4; + + /** + * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}. + * + * @return ScreenLength of zero length. + */ + @NonNull + @AnyThread + public static ScreenLength zero() { + return new ScreenLength(0.0, PIXEL); + } + + /** + * Create a ScreenLength of zero pixels length. Type is {@link #PIXEL}. Can be used to scroll to + * the top of a page when used with PanZoomController.scrollTo() + * + * @return ScreenLength of zero length. + */ + @NonNull + @AnyThread + public static ScreenLength top() { + return zero(); + } + + /** + * Create a ScreenLength of the documents height. Type is {@link #DOCUMENT_HEIGHT}. Can be used to + * scroll to the bottom of a page when used with {@link PanZoomController#scrollTo(ScreenLength, + * ScreenLength)} + * + * @return ScreenLength of document height. + */ + @NonNull + @AnyThread + public static ScreenLength bottom() { + return new ScreenLength(1.0, DOCUMENT_HEIGHT); + } + + /** + * Create a ScreenLength of a specific length. Type is {@link #PIXEL}. + * + * @param value Pixel length. + * @return ScreenLength of document height. + */ + @NonNull + @AnyThread + public static ScreenLength fromPixels(final double value) { + return new ScreenLength(value, PIXEL); + } + + /** + * Create a ScreenLength that uses the visual viewport width as units. Type is {@link + * #VISUAL_VIEWPORT_WIDTH}. Can be used with {@link PanZoomController#scrollBy(ScreenLength, + * ScreenLength)} to scroll a value of the width of visual viewport content. + * + * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as + * long as the length of the visual viewports width. + * @return ScreenLength of specifying a length of value * visual viewport width. + */ + @NonNull + @AnyThread + public static ScreenLength fromVisualViewportWidth(final double value) { + return new ScreenLength(value, VISUAL_VIEWPORT_WIDTH); + } + + /** + * Create a ScreenLength that uses the visual viewport width as units. Type is {@link + * #VISUAL_VIEWPORT_HEIGHT}. Can be used with {@link PanZoomController#scrollBy(ScreenLength, + * ScreenLength)} to scroll a value of the height of visual viewport content. + * + * @param value Factor used to calculate length. A value of 2.0 would indicate a length twice as + * long as the length of the visual viewports height. + * @return ScreenLength of specifying a length of value * visual viewport width. + */ + @NonNull + @AnyThread + public static ScreenLength fromVisualViewportHeight(final double value) { + return new ScreenLength(value, VISUAL_VIEWPORT_HEIGHT); + } + + private final double mValue; + @ScreenLengthType private final int mType; + + /* package */ ScreenLength(final double value, @ScreenLengthType final int type) { + mValue = value; + mType = type; + } + + /** + * Returns the scalar value used to calculate length. The units of the returned valued are defined + * by what is returned by {@link #getType()} + * + * @return Scalar value of the length. + */ + @AnyThread + public double getValue() { + return mValue; + } + + /** + * Returns the unit type of the length The length can be one of the following: {@link #PIXEL}, + * {@link #VISUAL_VIEWPORT_WIDTH}, {@link #VISUAL_VIEWPORT_HEIGHT}, {@link #DOCUMENT_WIDTH}, + * {@link #DOCUMENT_HEIGHT} + * + * @return Unit type of the length. + */ + @AnyThread + @ScreenLengthType + public int getType() { + return mType; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java new file mode 100644 index 0000000000..6e1439bb0a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionAccessibility.java @@ -0,0 +1,1331 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.content.Context; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.os.Build; +import android.os.Bundle; +import android.text.InputType; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.view.InputDevice; +import android.view.MotionEvent; +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.AccessibilityNodeInfo.CollectionInfo; +import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo; +import android.view.accessibility.AccessibilityNodeInfo.RangeInfo; +import android.view.accessibility.AccessibilityNodeProvider; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.util.Iterator; +import java.util.LinkedList; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.mozglue.JNIObject; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +@UiThread +public class SessionAccessibility { + private static final String LOGTAG = "GeckoAccessibility"; + + // This is the number BrailleBack uses to start indexing routing keys. + private static final int BRAILLE_CLICK_BASE_INDEX = -275000000; + private static final String ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE = + "ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE"; + + @WrapForJNI static final int FLAG_ACCESSIBILITY_FOCUSED = 0; + @WrapForJNI static final int FLAG_CHECKABLE = 1 << 1; + @WrapForJNI static final int FLAG_CHECKED = 1 << 2; + @WrapForJNI static final int FLAG_CLICKABLE = 1 << 3; + @WrapForJNI static final int FLAG_CONTENT_INVALID = 1 << 4; + @WrapForJNI static final int FLAG_CONTEXT_CLICKABLE = 1 << 5; + @WrapForJNI static final int FLAG_EDITABLE = 1 << 6; + @WrapForJNI static final int FLAG_ENABLED = 1 << 7; + @WrapForJNI static final int FLAG_FOCUSABLE = 1 << 8; + @WrapForJNI static final int FLAG_FOCUSED = 1 << 9; + @WrapForJNI static final int FLAG_LONG_CLICKABLE = 1 << 10; + @WrapForJNI static final int FLAG_MULTI_LINE = 1 << 11; + @WrapForJNI static final int FLAG_PASSWORD = 1 << 12; + @WrapForJNI static final int FLAG_SCROLLABLE = 1 << 13; + @WrapForJNI static final int FLAG_SELECTED = 1 << 14; + @WrapForJNI static final int FLAG_VISIBLE_TO_USER = 1 << 15; + @WrapForJNI static final int FLAG_SELECTABLE = 1 << 16; + @WrapForJNI static final int FLAG_EXPANDABLE = 1 << 17; + @WrapForJNI static final int FLAG_EXPANDED = 1 << 18; + + static final int CLASSNAME_UNKNOWN = -1; + @WrapForJNI static final int CLASSNAME_VIEW = 0; + @WrapForJNI static final int CLASSNAME_BUTTON = 1; + @WrapForJNI static final int CLASSNAME_CHECKBOX = 2; + @WrapForJNI static final int CLASSNAME_DIALOG = 3; + @WrapForJNI static final int CLASSNAME_EDITTEXT = 4; + @WrapForJNI static final int CLASSNAME_GRIDVIEW = 5; + @WrapForJNI static final int CLASSNAME_IMAGE = 6; + @WrapForJNI static final int CLASSNAME_LISTVIEW = 7; + @WrapForJNI static final int CLASSNAME_MENUITEM = 8; + @WrapForJNI static final int CLASSNAME_PROGRESSBAR = 9; + @WrapForJNI static final int CLASSNAME_RADIOBUTTON = 10; + @WrapForJNI static final int CLASSNAME_SEEKBAR = 11; + @WrapForJNI static final int CLASSNAME_SPINNER = 12; + @WrapForJNI static final int CLASSNAME_TABWIDGET = 13; + @WrapForJNI static final int CLASSNAME_TOGGLEBUTTON = 14; + @WrapForJNI static final int CLASSNAME_WEBVIEW = 15; + + private static final String[] CLASSNAMES = { + "android.view.View", + "android.widget.Button", + "android.widget.CheckBox", + "android.app.Dialog", + "android.widget.EditText", + "android.widget.GridView", + "android.widget.Image", + "android.widget.ListView", + "android.view.MenuItem", + "android.widget.ProgressBar", + "android.widget.RadioButton", + "android.widget.SeekBar", + "android.widget.Spinner", + "android.widget.TabWidget", + "android.widget.ToggleButton", + "android.webkit.WebView" + }; + + @WrapForJNI static final int HTML_GRANULARITY_DEFAULT = -1; + @WrapForJNI static final int HTML_GRANULARITY_ARTICLE = 0; + @WrapForJNI static final int HTML_GRANULARITY_BUTTON = 1; + @WrapForJNI static final int HTML_GRANULARITY_CHECKBOX = 2; + @WrapForJNI static final int HTML_GRANULARITY_COMBOBOX = 3; + @WrapForJNI static final int HTML_GRANULARITY_CONTROL = 4; + @WrapForJNI static final int HTML_GRANULARITY_FOCUSABLE = 5; + @WrapForJNI static final int HTML_GRANULARITY_FRAME = 6; + @WrapForJNI static final int HTML_GRANULARITY_GRAPHIC = 7; + @WrapForJNI static final int HTML_GRANULARITY_H1 = 8; + @WrapForJNI static final int HTML_GRANULARITY_H2 = 9; + @WrapForJNI static final int HTML_GRANULARITY_H3 = 10; + @WrapForJNI static final int HTML_GRANULARITY_H4 = 11; + @WrapForJNI static final int HTML_GRANULARITY_H5 = 12; + @WrapForJNI static final int HTML_GRANULARITY_H6 = 13; + @WrapForJNI static final int HTML_GRANULARITY_HEADING = 14; + @WrapForJNI static final int HTML_GRANULARITY_LANDMARK = 15; + @WrapForJNI static final int HTML_GRANULARITY_LINK = 16; + @WrapForJNI static final int HTML_GRANULARITY_LIST = 17; + @WrapForJNI static final int HTML_GRANULARITY_LIST_ITEM = 18; + @WrapForJNI static final int HTML_GRANULARITY_MAIN = 19; + @WrapForJNI static final int HTML_GRANULARITY_MEDIA = 20; + @WrapForJNI static final int HTML_GRANULARITY_RADIO = 21; + @WrapForJNI static final int HTML_GRANULARITY_SECTION = 22; + @WrapForJNI static final int HTML_GRANULARITY_TABLE = 23; + @WrapForJNI static final int HTML_GRANULARITY_TEXT_FIELD = 24; + @WrapForJNI static final int HTML_GRANULARITY_UNVISITED_LINK = 25; + @WrapForJNI static final int HTML_GRANULARITY_VISITED_LINK = 26; + + private static String[] sHtmlGranularities = { + "ARTICLE", + "BUTTON", + "CHECKBOX", + "COMBOBOX", + "CONTROL", + "FOCUSABLE", + "FRAME", + "GRAPHIC", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEADING", + "LANDMARK", + "LINK", + "LIST", + "LIST_ITEM", + "MAIN", + "MEDIA", + "RADIO", + "SECTION", + "TABLE", + "TEXT_FIELD", + "UNVISITED_LINK", + "VISITED_LINK" + }; + + private static String getClassName(final int index) { + if (index >= 0 && index < CLASSNAMES.length) { + return CLASSNAMES[index]; + } + + Log.e(LOGTAG, "Index " + index + " our of CLASSNAME bounds."); + return "android.view.View"; // Fallback class is View + } + + /* package */ final class NodeProvider extends AccessibilityNodeProvider { + @Override + public AccessibilityNodeInfo createAccessibilityNodeInfo(final int virtualDescendantId) { + AccessibilityNodeInfo node = null; + if (mAttached) { + node = + mSession.getSettings().getFullAccessibilityTree() || isCacheEnabled() + ? getNodeFromGecko(virtualDescendantId) + : getNodeFromCache(virtualDescendantId); + } + + if (node == null) { + Log.w( + LOGTAG, + "Failed to retrieve accessible node virtualDescendantId=" + + virtualDescendantId + + " mAttached=" + + mAttached); + node = AccessibilityNodeInfo.obtain(mView, View.NO_ID); + if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) { + // When running junit tests we don't have a display + mView.onInitializeAccessibilityNodeInfo(node); + } + node.setClassName("android.webkit.WebView"); + } + + return node; + } + + @Override + public boolean performAction( + final int virtualViewId, final int action, final Bundle arguments) { + final GeckoBundle data; + + switch (action) { + case AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS: + sendEvent( + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED, + virtualViewId, + CLASSNAME_UNKNOWN, + null); + return true; + case AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS: + sendEvent( + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, + virtualViewId, + virtualViewId == View.NO_ID ? CLASSNAME_WEBVIEW : CLASSNAME_UNKNOWN, + null); + return true; + case AccessibilityNodeInfo.ACTION_CLICK: + case AccessibilityNodeInfo.ACTION_EXPAND: + case AccessibilityNodeInfo.ACTION_COLLAPSE: + nativeProvider.click(virtualViewId); + final GeckoBundle nodeInfo = getMostRecentBundle(virtualViewId); + if (nodeInfo != null) { + if ((nodeInfo.getInt("flags") & (FLAG_SELECTABLE | FLAG_CHECKABLE | FLAG_EXPANDABLE)) + == 0) { + sendEvent( + AccessibilityEvent.TYPE_VIEW_CLICKED, + virtualViewId, + nodeInfo.getInt("className"), + null); + } + } + return true; + case AccessibilityNodeInfo.ACTION_LONG_CLICK: + // XXX: Implement long press. + return true; + case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: + if (virtualViewId == View.NO_ID) { + // Scroll the viewport forwards by approximately 80%. + mSession + .getPanZoomController() + .scrollBy( + ScreenLength.zero(), + ScreenLength.fromVisualViewportHeight(0.8), + PanZoomController.SCROLL_BEHAVIOR_AUTO); + } else { + // XXX: It looks like we never call scroll on virtual views. + // If we did, we should synthesize a wheel event on it's center coordinate. + } + return true; + case AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD: + if (virtualViewId == View.NO_ID) { + // Scroll the viewport backwards by approximately 80%. + mSession + .getPanZoomController() + .scrollBy( + ScreenLength.zero(), + ScreenLength.fromVisualViewportHeight(-0.8), + PanZoomController.SCROLL_BEHAVIOR_AUTO); + } else { + // XXX: It looks like we never call scroll on virtual views. + // If we did, we should synthesize a wheel event on it's center coordinate. + } + return true; + case AccessibilityNodeInfo.ACTION_SELECT: + nativeProvider.click(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT: + requestViewFocus(); + return pivot( + virtualViewId, + arguments != null + ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING) + : "", + true, + false); + case AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT: + requestViewFocus(); + return pivot( + virtualViewId, + arguments != null + ? arguments.getString(AccessibilityNodeInfo.ACTION_ARGUMENT_HTML_ELEMENT_STRING) + : "", + false, + false); + case AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY: + case AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY: + // 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 + if (arguments == null) { + return false; + } + final int granularity = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_MOVEMENT_GRANULARITY_INT); + if (granularity <= BRAILLE_CLICK_BASE_INDEX) { + // XXX: Use click offset to update caret position in editables (BRAILLE_CLICK_BASE_INDEX + // - granularity). + nativeProvider.click(virtualViewId); + } else if (granularity > 0) { + final boolean extendSelection = + arguments.getBoolean( + AccessibilityNodeInfo.ACTION_ARGUMENT_EXTEND_SELECTION_BOOLEAN); + final boolean next = + action == AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY; + // We must return false if we're already at the edge. + if (next) { + if (mAtEndOfText) { + return false; + } + if (granularity == AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD && mAtLastWord) { + return false; + } + } else if (mAtStartOfText) { + return false; + } + nativeProvider.navigateText( + virtualViewId, granularity, mStartOffset, mEndOffset, next, extendSelection); + } + return true; + case AccessibilityNodeInfo.ACTION_SET_SELECTION: + if (arguments == null) { + return false; + } + final int selectionStart = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_START_INT); + final int selectionEnd = + arguments.getInt(AccessibilityNodeInfo.ACTION_ARGUMENT_SELECTION_END_INT); + nativeProvider.setSelection(virtualViewId, selectionStart, selectionEnd); + return true; + case AccessibilityNodeInfo.ACTION_CUT: + nativeProvider.cut(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_COPY: + nativeProvider.copy(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_PASTE: + nativeProvider.paste(virtualViewId); + return true; + case AccessibilityNodeInfo.ACTION_SET_TEXT: + if (arguments == null) { + return false; + } + final String value = + arguments.getString( + Build.VERSION.SDK_INT >= 21 + ? AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE + : ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE); + if (mAttached) { + nativeProvider.setText(virtualViewId, value); + } + return true; + } + + return mView.performAccessibilityAction(action, arguments); + } + + @Override + public AccessibilityNodeInfo findFocus(final int focus) { + switch (focus) { + case AccessibilityNodeInfo.FOCUS_ACCESSIBILITY: + if (mAccessibilityFocusedNode != 0) { + return createAccessibilityNodeInfo(mAccessibilityFocusedNode); + } + break; + case AccessibilityNodeInfo.FOCUS_INPUT: + if (mFocusedNode != 0) { + return createAccessibilityNodeInfo(mFocusedNode); + } + break; + } + + return super.findFocus(focus); + } + + private AccessibilityNodeInfo getNodeFromGecko(final int virtualViewId) { + ThreadUtils.assertOnUiThread(); + final AccessibilityNodeInfo node = AccessibilityNodeInfo.obtain(mView, virtualViewId); + nativeProvider.getNodeInfo(virtualViewId, node); + + // We set the bounds in parent here because we need to use the client-to-screen matrix + // and it is only available in the UI thread. + final Rect bounds = new Rect(); + node.getBoundsInParent(bounds); + + final Matrix matrix = new Matrix(); + mSession.getClientToScreenMatrix(matrix); + final float[] origin = new float[2]; + matrix.mapPoints(origin); + bounds.offset((int) origin[0], (int) origin[1]); + node.setBoundsInScreen(bounds); + + return node; + } + + private AccessibilityNodeInfo getNodeFromCache(final int virtualViewId) { + synchronized (SessionAccessibility.this) { + AccessibilityNodeInfo node = null; + for (final SparseArray<GeckoBundle> cache : mCaches) { + final GeckoBundle bundle = cache.get(virtualViewId); + if (bundle == null) { + continue; + } + + if (node == null) { + node = AccessibilityNodeInfo.obtain(mView, virtualViewId); + } + populateNodeFromBundle(node, bundle, true); + } + + if (node == null) { + Log.e(LOGTAG, "No cached node for " + virtualViewId); + } + + return node; + } + } + + private void populateNodeFromBundle( + final AccessibilityNodeInfo node, final GeckoBundle nodeInfo, final boolean fromCache) { + if (mView == null || nodeInfo == null) { + return; + } + + final int id = nodeInfo.getInt("id"); + final boolean isRoot = id == View.NO_ID; + if (isRoot) { + if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) { + // When running junit tests we don't have a display + mView.onInitializeAccessibilityNodeInfo(node); + } + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + } else { + node.setParent(mView, nodeInfo.getInt("parentId", View.NO_ID)); + } + + final int flags = nodeInfo.getInt("flags"); + + // The basics + node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + node.setClassName(getClassName(nodeInfo.getInt("className"))); + + if (nodeInfo.containsKey("text")) { + node.setText(nodeInfo.getString("text")); + } + + if (nodeInfo.containsKey("description")) { + node.setContentDescription(nodeInfo.getString("description")); + } + + // Add actions + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); + node.setMovementGranularities( + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); + if ((flags & FLAG_CLICKABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_CLICK); + } + + // Set boolean properties + node.setCheckable((flags & FLAG_CHECKABLE) != 0); + node.setChecked((flags & FLAG_CHECKED) != 0); + node.setClickable((flags & FLAG_CLICKABLE) != 0); + node.setEnabled((flags & FLAG_ENABLED) != 0); + node.setFocusable((flags & FLAG_FOCUSABLE) != 0); + node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0); + node.setPassword((flags & FLAG_PASSWORD) != 0); + node.setScrollable((flags & FLAG_SCROLLABLE) != 0); + node.setSelected((flags & FLAG_SELECTED) != 0); + node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0); + // Other boolean properties to consider later: + // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText, + // setDismissable + + if (mAccessibilityFocusedNode == id) { + node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); + node.setAccessibilityFocused(true); + } else { + node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); + } + node.setFocused(mFocusedNode == id); + + // Bounds + final int[] b = nodeInfo.getIntArray("bounds"); + if (b != null) { + final Rect screenBounds = new Rect(b[0], b[1], b[2], b[3]); + node.setBoundsInScreen(screenBounds); + + final Matrix matrix = new Matrix(); + mSession.getClientToScreenMatrix(matrix); + final float[] origin = new float[2]; + matrix.mapPoints(origin); + final Rect parentBounds = + new Rect(b[0] - (int) origin[0], b[1] - (int) origin[1], b[2], b[3]); + node.setBoundsInParent(parentBounds); + } + + // Children + final int[] children = nodeInfo.getIntArray("children"); + if (node.getChildCount() == 0 && children != null) { + for (final int childId : children) { + final GeckoBundle childBundle = getMostRecentBundle(childId); + if (!fromCache || (childBundle != null && childBundle.getInt("parentId") == id)) { + // If this node is from cache, only populate with children that are cached as well. + node.addChild(mView, childId); + } + } + } + + // SDK 18 and above + if (Build.VERSION.SDK_INT >= 18) { + node.setViewIdResourceName(nodeInfo.getString("viewIdResourceName")); + + if ((flags & FLAG_EDITABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); + node.addAction(AccessibilityNodeInfo.ACTION_CUT); + node.addAction(AccessibilityNodeInfo.ACTION_COPY); + node.addAction(AccessibilityNodeInfo.ACTION_PASTE); + node.setEditable(true); + } + } + + // SDK 19 and above + if (Build.VERSION.SDK_INT >= 19) { + node.setMultiLine((flags & FLAG_MULTI_LINE) != 0); + node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0); + + // Set bundle keys like role and hint + final Bundle bundle = node.getExtras(); + if (nodeInfo.containsKey("hint")) { + final String hint = nodeInfo.getString("hint"); + bundle.putCharSequence("AccessibilityNodeInfo.hint", hint); + if (Build.VERSION.SDK_INT >= 26) { + node.setHintText(hint); + } + } + if (nodeInfo.containsKey("geckoRole")) { + bundle.putCharSequence( + "AccessibilityNodeInfo.geckoRole", nodeInfo.getString("geckoRole")); + } + if (nodeInfo.containsKey("roleDescription")) { + bundle.putCharSequence( + "AccessibilityNodeInfo.roleDescription", nodeInfo.getString("roleDescription")); + } + if (isRoot) { + // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT. + // This is mostly here to let TalkBack know we are a legit "WebView". + bundle.putCharSequence( + "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES", + TextUtils.join(",", sHtmlGranularities)); + } + + // Set RangeInfo + final GeckoBundle rangeBundle = nodeInfo.getBundle("rangeInfo"); + if (rangeBundle != null) { + final RangeInfo rangeInfo = + RangeInfo.obtain( + rangeBundle.getInt("type"), + (float) rangeBundle.getDouble("min", Float.NEGATIVE_INFINITY), + (float) rangeBundle.getDouble("max", Float.POSITIVE_INFINITY), + (float) rangeBundle.getDouble("current", 0)); + node.setRangeInfo(rangeInfo); + } + + // Set CollectionItemInfo + final GeckoBundle collectionItemBundle = nodeInfo.getBundle("collectionItemInfo"); + if (collectionItemBundle != null) { + final CollectionItemInfo collectionItemInfo = + CollectionItemInfo.obtain( + collectionItemBundle.getInt("rowIndex"), + collectionItemBundle.getInt("rowSpan"), + collectionItemBundle.getInt("columnIndex"), + collectionItemBundle.getInt("columnSpan"), + false); + node.setCollectionItemInfo(collectionItemInfo); + } + + // Set CollectionInfo + final GeckoBundle collectionBundle = nodeInfo.getBundle("collectionInfo"); + if (collectionBundle != null) { + // selectionMode is only supported in SDK >= 21. + final CollectionInfo collectionInfo = + Build.VERSION.SDK_INT >= 21 + ? CollectionInfo.obtain( + collectionBundle.getInt("rowCount"), + collectionBundle.getInt("columnCount"), + collectionBundle.getBoolean("isHierarchical", false), + collectionBundle.getInt("selectionMode", 0)) + : CollectionInfo.obtain( + collectionBundle.getInt("rowCount"), + collectionBundle.getInt("columnCount"), + collectionBundle.getBoolean("isHierarchical", false)); + node.setCollectionInfo(collectionInfo); + } + + node.setInputType(nodeInfo.getInt("inputType")); + } + + // SDK 21 and above + if (Build.VERSION.SDK_INT >= 21) { + if ((flags & FLAG_EXPANDABLE) != 0) { + if ((flags & FLAG_EXPANDED) != 0) { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + } else { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + } + } + } + + // SDK 23 and above + if (Build.VERSION.SDK_INT >= 23) { + node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0); + } + } + } + + // Gecko session we are proxying + /* package */ final GeckoSession mSession; + // This is the view that delegates accessibility to us. We also sends event through it. + private View mView; + // The native portion of the node provider. + /* package */ final NativeProvider nativeProvider = new NativeProvider(); + private boolean mAttached = false; + // The current node with accessibility focus + private int mAccessibilityFocusedNode = 0; + // The first accessibility focusable node + private int mFirstAccessibilityFocusable = 0; + // The last accessibility focusable node + private int mLastAccessibilityFocusable = 0; + // The current node with focus + private int mFocusedNode = 0; + private int mStartOffset = -1; + private int mEndOffset = -1; + private boolean mAtStartOfText = false; + private boolean mAtEndOfText = false; + private boolean mAtLastWord = false; + // Viewport cache + final SparseArray<GeckoBundle> mViewportCache = new SparseArray<>(); + // Focus cache + final SparseArray<GeckoBundle> mFocusPathCache = new SparseArray<>(); + // List of caches in descending order from last updated. + LinkedList<SparseArray<GeckoBundle>> mCaches = new LinkedList<>(); + private boolean mViewFocusRequested = false; + + /* package */ SessionAccessibility(final GeckoSession session) { + mSession = session; + Settings.updateAccessibilitySettings(); + } + + /* package */ static void setForceEnabled(final boolean forceEnabled) { + Settings.setForceEnabled(forceEnabled); + } + + /** + * Get the View instance that delegates accessibility to this session. + * + * @return View instance. + */ + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + + return mView; + } + + /** + * Set the View instance that should delegate accessibility to this session. + * + * @param view View instance. + */ + @UiThread + public void setView(final @Nullable View view) { + ThreadUtils.assertOnUiThread(); + + if (mView != null) { + mView.setAccessibilityDelegate(null); + } + + mView = view; + + if (mView == null) { + return; + } + + mView.setAccessibilityDelegate( + new View.AccessibilityDelegate() { + private NodeProvider mProvider; + + @Override + public AccessibilityNodeProvider getAccessibilityNodeProvider(final View hostView) { + if (hostView != mView) { + return null; + } + if (mProvider == null) { + mProvider = new NodeProvider(); + } + return mProvider; + } + + @Override + public void sendAccessibilityEvent(final View host, final int eventType) { + if (eventType == AccessibilityEvent.TYPE_VIEW_FOCUSED) { + // We rely on the focus events sent from Gecko. + return; + } + + super.sendAccessibilityEvent(host, eventType); + } + }); + } + + private boolean isInTest() { + return Build.VERSION.SDK_INT >= 17 && mView != null && mView.getDisplay() == null; + } + + private void requestViewFocus() { + if (!mView.isFocused() && !isInTest()) { + mViewFocusRequested = true; + mView.requestFocus(); + } + } + + private static class Settings { + private static volatile boolean sEnabled; + private static volatile boolean sTouchExplorationEnabled; + private static volatile boolean sForceEnabled; + + public static void setForceEnabled(final boolean forceEnabled) { + sForceEnabled = forceEnabled; + dispatch(); + } + + static { + final Context context = GeckoAppShell.getApplicationContext(); + final AccessibilityManager accessibilityManager = + (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + + accessibilityManager.addAccessibilityStateChangeListener( + enabled -> updateAccessibilitySettings()); + + if (Build.VERSION.SDK_INT >= 19) { + accessibilityManager.addTouchExplorationStateChangeListener( + enabled -> updateAccessibilitySettings()); + } + } + + public static boolean isEnabled() { + return sEnabled || sForceEnabled; + } + + public static boolean isTouchExplorationEnabled() { + return sTouchExplorationEnabled || sForceEnabled; + } + + public static void updateAccessibilitySettings() { + final AccessibilityManager accessibilityManager = + (AccessibilityManager) + GeckoAppShell.getApplicationContext().getSystemService(Context.ACCESSIBILITY_SERVICE); + sEnabled = accessibilityManager.isEnabled(); + sTouchExplorationEnabled = sEnabled && accessibilityManager.isTouchExplorationEnabled(); + dispatch(); + } + + /* package */ static void dispatch() { + if (GeckoThread.isStateAtLeast(GeckoThread.State.PROFILE_READY)) { + toggleNativeAccessibility(isEnabled()); + } else { + GeckoThread.queueNativeCallUntil( + GeckoThread.State.PROFILE_READY, + Settings.class, + "toggleNativeAccessibility", + isEnabled()); + } + } + + @WrapForJNI(dispatchTo = "gecko") + private static native void toggleNativeAccessibility(boolean enable); + } + + @SuppressWarnings("checkstyle:javadocmethod") + public boolean onMotionEvent(final @NonNull MotionEvent event) { + ThreadUtils.assertOnUiThread(); + + if (!Settings.isTouchExplorationEnabled()) { + return false; + } + + if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN) { + return false; + } + + final int action = event.getActionMasked(); + if ((action != MotionEvent.ACTION_HOVER_MOVE) + && (action != MotionEvent.ACTION_HOVER_ENTER) + && (action != MotionEvent.ACTION_HOVER_EXIT)) { + return false; + } + + requestViewFocus(); + + nativeProvider.exploreByTouch( + mAccessibilityFocusedNode != 0 ? mAccessibilityFocusedNode : View.NO_ID, + event.getX(), + event.getY()); + + return true; + } + + /* package */ void sendEvent( + final int eventType, final int sourceId, final int className, final GeckoBundle eventData) { + ThreadUtils.assertOnUiThread(); + if (mView == null) { + return; + } + + if (mViewFocusRequested && className == CLASSNAME_WEBVIEW) { + // If the view was focused from an accessiblity action or + // explore-by-touch, we supress this focus event to avoid noise. + mViewFocusRequested = false; + return; + } + + final GeckoBundle cachedBundle = getMostRecentBundle(sourceId); + if (cachedBundle == null && sourceId != View.NO_ID && !isCacheEnabled()) { + // Suppress events from non cached nodes. + return; + } + + final AccessibilityEvent event = AccessibilityEvent.obtain(eventType); + event.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + event.setSource(mView, sourceId); + event.setEnabled(true); + + int eventClassName = className; + if (eventClassName == CLASSNAME_UNKNOWN) { + if (cachedBundle != null) { + eventClassName = cachedBundle.getInt("className"); + } else if (isCacheEnabled()) { + eventClassName = nativeProvider.getNodeClassName(sourceId); + } + } + event.setClassName(getClassName(eventClassName)); + + if (eventData != null) { + if (eventData.containsKey("text")) { + event.getText().add(eventData.getString("text")); + } + event.setContentDescription(eventData.getString("description", "")); + event.setAddedCount(eventData.getInt("addedCount", -1)); + event.setRemovedCount(eventData.getInt("removedCount", -1)); + event.setFromIndex(eventData.getInt("fromIndex", -1)); + event.setItemCount(eventData.getInt("itemCount", -1)); + event.setCurrentItemIndex(eventData.getInt("currentItemIndex", -1)); + event.setBeforeText(eventData.getString("beforeText", "")); + event.setToIndex(eventData.getInt("toIndex", -1)); + event.setScrollX(eventData.getInt("scrollX", -1)); + event.setScrollY(eventData.getInt("scrollY", -1)); + event.setMaxScrollX(eventData.getInt("maxScrollX", -1)); + event.setMaxScrollY(eventData.getInt("maxScrollY", -1)); + event.setChecked((eventData.getInt("flags") & FLAG_CHECKED) != 0); + } + + // Update cache and stored state from this event. + switch (eventType) { + case AccessibilityEvent.TYPE_VIEW_CLICKED: + if (cachedBundle != null && eventData != null && eventData.containsKey("flags")) { + final int flags = eventData.getInt("flags"); + if ((flags & FLAG_CHECKABLE) != 0) { + if ((flags & FLAG_CHECKED) != 0) { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_CHECKED); + } else { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_CHECKED); + } + } + + if ((flags & FLAG_EXPANDABLE) != 0) { + if ((flags & FLAG_EXPANDED) != 0) { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_EXPANDED); + } else { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_EXPANDED); + } + } + } + break; + case AccessibilityEvent.TYPE_VIEW_SELECTED: + if (cachedBundle != null && eventData != null && eventData.containsKey("selected")) { + if (eventData.getInt("selected") != 0) { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") | FLAG_SELECTED); + } else { + cachedBundle.putInt("flags", cachedBundle.getInt("flags") & ~FLAG_SELECTED); + } + } + break; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED: + if (mAccessibilityFocusedNode == sourceId) { + mAccessibilityFocusedNode = 0; + } + break; + case AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED: + mStartOffset = -1; + mEndOffset = -1; + mAtStartOfText = false; + mAtEndOfText = false; + mAtLastWord = false; + mAccessibilityFocusedNode = sourceId; + break; + case AccessibilityEvent.TYPE_VIEW_FOCUSED: + mFocusedNode = sourceId; + if (!mView.isFocused() && !isInTest()) { + // Don't dispatch a focus event if the parent view is not focused + return; + } + break; + case AccessibilityEvent.TYPE_VIEW_TEXT_TRAVERSED_AT_MOVEMENT_GRANULARITY: + mStartOffset = event.getFromIndex(); + mEndOffset = event.getToIndex(); + // We must synchronously return false for text navigation + // actions if the user attempts to navigate past the edge. + // Because we do navigation async, we can't query this + // on demand when the action is performed. Therefore, we cache + // whether we're at either edge here. + mAtStartOfText = mStartOffset == 0; + final CharSequence text = event.getText().get(0); + mAtEndOfText = mEndOffset >= text.length(); + mAtLastWord = mAtEndOfText; + if (!mAtLastWord) { + // Words exclude trailing spaces. To figure out whether + // we're at the last word, we need to get the text after + // our end offset and check if it's just spaces. + final CharSequence afterText = text.subSequence(mEndOffset, text.length()); + if (TextUtils.getTrimmedLength(afterText) == 0) { + mAtLastWord = true; + } + } + break; + } + + try { + ((ViewParent) mView).requestSendAccessibilityEvent(mView, event); + } catch (final IllegalStateException ex) { + // Accessibility could be activated in Gecko via xpcom, for example when using a11y + // devtools. Events that are forwarded to the platform will throw an exception. + } + } + + private synchronized GeckoBundle getMostRecentBundle(final int virtualViewId) { + final Iterator<SparseArray<GeckoBundle>> iter = mCaches.descendingIterator(); + while (iter.hasNext()) { + final GeckoBundle bundle = iter.next().get(virtualViewId); + if (bundle != null) { + return bundle; + } + } + + return null; + } + + private boolean pivot( + final int id, final String granularity, final boolean forward, final boolean inclusive) { + if (!forward && id == View.NO_ID) { + // If attempting to pivot backwards from the root view, return false. + return false; + } + + if (isCacheEnabled()) { + return cachedPivot(id, granularity, forward, inclusive); + } + + final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity); + if (forward && id == mLastAccessibilityFocusable) { + return false; + } + + if (!forward) { + if (id == mFirstAccessibilityFocusable) { + sendEvent( + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, + View.NO_ID, + CLASSNAME_WEBVIEW, + null); + return true; + } + } + + nativeProvider.pivotNative(id, gran, forward, inclusive); + return true; + } + + private boolean cachedPivot( + final int id, final String granularity, final boolean forward, final boolean inclusive) { + final int gran = java.util.Arrays.asList(sHtmlGranularities).indexOf(granularity); + final boolean success = nativeProvider.cachedPivotNative(id, gran, forward, inclusive); + if (!success && !forward) { + // If we failed to pivot backwards set the root view as the a11y focus. + sendEvent( + AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED, View.NO_ID, CLASSNAME_WEBVIEW, null); + return true; + } + + return success; + } + + private boolean isCacheEnabled() { + return mAttached && nativeProvider.isCacheEnabled(); + } + + /* package */ final class NativeProvider extends JNIObject { + @WrapForJNI(calledFrom = "ui") + private void setAttached(final boolean attached) { + mAttached = attached; + } + + @Override // JNIObject + protected void disposeNative() { + // Disposal happens in native code. + throw new UnsupportedOperationException(); + } + + @WrapForJNI(dispatchTo = "current") + public native boolean isCacheEnabled(); + + @WrapForJNI(dispatchTo = "current") + public native void getNodeInfo(int id, AccessibilityNodeInfo nodeInfo); + + @WrapForJNI(dispatchTo = "current") + public native int getNodeClassName(int id); + + @WrapForJNI(dispatchTo = "gecko") + public native void setText(int id, String text); + + @WrapForJNI(dispatchTo = "gecko") + public native void click(int id); + + @WrapForJNI(dispatchTo = "gecko", stubName = "Pivot") + public native void pivotNative(int id, int granularity, boolean forward, boolean inclusive); + + @WrapForJNI(dispatchTo = "current", stubName = "CachedPivot") + public native boolean cachedPivotNative( + int id, int granularity, boolean forward, boolean inclusive); + + @WrapForJNI(dispatchTo = "gecko") + public native void exploreByTouch(int id, float x, float y); + + @WrapForJNI(dispatchTo = "gecko") + public native void navigateText( + int id, int granularity, int startOffset, int endOffset, boolean forward, boolean select); + + @WrapForJNI(dispatchTo = "gecko") + public native void setSelection(int id, int start, int end); + + @WrapForJNI(dispatchTo = "gecko") + public native void cut(int id); + + @WrapForJNI(dispatchTo = "gecko") + public native void copy(int id); + + @WrapForJNI(dispatchTo = "gecko") + public native void paste(int id); + + @WrapForJNI(calledFrom = "gecko", stubName = "SendEvent") + private void sendEventNative( + final int eventType, final int sourceId, final int className, final GeckoBundle eventData) { + ThreadUtils.runOnUiThread( + new Runnable() { + @Override + public void run() { + sendEvent(eventType, sourceId, className, eventData); + } + }); + } + + @WrapForJNI(calledFrom = "gecko") + private void replaceViewportCache(final GeckoBundle[] bundles) { + synchronized (SessionAccessibility.this) { + mViewportCache.clear(); + for (final GeckoBundle bundle : bundles) { + if (bundle == null) { + continue; + } + mViewportCache.append(bundle.getInt("id"), bundle); + } + mCaches.remove(mViewportCache); + mCaches.add(mViewportCache); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void replaceFocusPathCache(final GeckoBundle[] bundles) { + synchronized (SessionAccessibility.this) { + mFocusPathCache.clear(); + for (final GeckoBundle bundle : bundles) { + if (bundle == null) { + continue; + } + mFocusPathCache.append(bundle.getInt("id"), bundle); + } + mCaches.remove(mFocusPathCache); + mCaches.add(mFocusPathCache); + } + } + + @WrapForJNI(calledFrom = "gecko") + private void updateCachedBounds(final GeckoBundle[] bundles) { + synchronized (SessionAccessibility.this) { + for (final GeckoBundle bundle : bundles) { + final GeckoBundle cachedBundle = getMostRecentBundle(bundle.getInt("id")); + if (cachedBundle == null) { + Log.e(LOGTAG, "Can't update bounds of uncached node " + bundle.getInt("id")); + continue; + } + cachedBundle.putIntArray("bounds", bundle.getIntArray("bounds")); + } + } + } + + @WrapForJNI(calledFrom = "gecko") + private void updateAccessibleFocusBoundaries(final int firstNode, final int lastNode) { + synchronized (SessionAccessibility.this) { + mFirstAccessibilityFocusable = firstNode; + mLastAccessibilityFocusable = lastNode; + } + } + + @WrapForJNI + private void populateNodeInfo( + final AccessibilityNodeInfo node, + final int id, + final int parentId, + final int[] children, + final int flags, + final int className, + final int[] bounds, + @Nullable final String text, + @Nullable final String description, + @Nullable final String hint, + @Nullable final String geckoRole, + @Nullable final String roleDescription, + @Nullable final String viewIdResourceName, + final int inputType) { + if (mView == null) { + return; + } + + final boolean isRoot = id == View.NO_ID; + if (isRoot) { + if (Build.VERSION.SDK_INT < 17 || mView.getDisplay() != null) { + // When running junit tests we don't have a display + mView.onInitializeAccessibilityNodeInfo(node); + } + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD); + node.addAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); + } else { + node.setParent(mView, parentId); + } + + // The basics + node.setPackageName(GeckoAppShell.getApplicationContext().getPackageName()); + node.setClassName(getClassName(className)); + + if (text != null) { + node.setText(text); + } + + if (description != null) { + node.setContentDescription(description); + } + + // Add actions + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_HTML_ELEMENT); + node.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY); + node.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY); + node.setMovementGranularities( + AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE + | AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH); + if ((flags & FLAG_CLICKABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_CLICK); + } + + // Set boolean properties + node.setCheckable((flags & FLAG_CHECKABLE) != 0); + node.setChecked((flags & FLAG_CHECKED) != 0); + node.setClickable((flags & FLAG_CLICKABLE) != 0); + node.setEnabled((flags & FLAG_ENABLED) != 0); + node.setFocusable((flags & FLAG_FOCUSABLE) != 0); + node.setLongClickable((flags & FLAG_LONG_CLICKABLE) != 0); + node.setPassword((flags & FLAG_PASSWORD) != 0); + node.setScrollable((flags & FLAG_SCROLLABLE) != 0); + node.setSelected((flags & FLAG_SELECTED) != 0); + node.setVisibleToUser((flags & FLAG_VISIBLE_TO_USER) != 0); + // Other boolean properties to consider later: + // setHeading, setImportantForAccessibility, setScreenReaderFocusable, setShowingHintText, + // setDismissable + + if (mAccessibilityFocusedNode == id) { + node.addAction(AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS); + node.setAccessibilityFocused(true); + } else { + node.addAction(AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS); + } + node.setFocused(mFocusedNode == id); + + final Rect parentBounds = new Rect(bounds[0], bounds[1], bounds[2], bounds[3]); + node.setBoundsInParent(parentBounds); + + for (final int childId : children) { + node.addChild(mView, childId); + } + + // SDK 18 and above + if (Build.VERSION.SDK_INT >= 18) { + node.setViewIdResourceName(viewIdResourceName); + + if ((flags & FLAG_EDITABLE) != 0) { + node.addAction(AccessibilityNodeInfo.ACTION_SET_SELECTION); + node.addAction(AccessibilityNodeInfo.ACTION_CUT); + node.addAction(AccessibilityNodeInfo.ACTION_COPY); + node.addAction(AccessibilityNodeInfo.ACTION_PASTE); + node.setEditable(true); + } + } + + // SDK 19 and above + if (Build.VERSION.SDK_INT >= 19) { + node.setMultiLine((flags & FLAG_MULTI_LINE) != 0); + node.setContentInvalid((flags & FLAG_CONTENT_INVALID) != 0); + + // Set bundle keys like role and hint + final Bundle bundle = node.getExtras(); + if (hint != null) { + bundle.putCharSequence("AccessibilityNodeInfo.hint", hint); + if (Build.VERSION.SDK_INT >= 26) { + node.setHintText(hint); + } + } + if (geckoRole != null) { + bundle.putCharSequence("AccessibilityNodeInfo.geckoRole", geckoRole); + } + if (roleDescription != null) { + bundle.putCharSequence("AccessibilityNodeInfo.roleDescription", roleDescription); + } + if (isRoot) { + // Argument values for ACTION_NEXT_HTML_ELEMENT/ACTION_PREVIOUS_HTML_ELEMENT. + // This is mostly here to let TalkBack know we are a legit "WebView". + bundle.putCharSequence( + "ACTION_ARGUMENT_HTML_ELEMENT_STRING_VALUES", + TextUtils.join(",", sHtmlGranularities)); + } + + if (inputType != InputType.TYPE_NULL) { + node.setInputType(inputType); + } + } + + // SDK 21 and above + if (Build.VERSION.SDK_INT >= 21) { + if ((flags & FLAG_EXPANDABLE) != 0) { + if ((flags & FLAG_EXPANDED) != 0) { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + } else { + node.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_COLLAPSE); + node.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_EXPAND); + } + } + } + + // SDK 23 and above + if (Build.VERSION.SDK_INT >= 23) { + node.setContextClickable((flags & FLAG_CONTEXT_CLICKABLE) != 0); + } + } + + @WrapForJNI + private void populateNodeCollectionItemInfo( + final AccessibilityNodeInfo node, + final int rowIndex, + final int rowSpan, + final int columnIndex, + final int columnSpan) { + final CollectionItemInfo collectionItemInfo = + CollectionItemInfo.obtain(rowIndex, rowSpan, columnIndex, columnSpan, false); + node.setCollectionItemInfo(collectionItemInfo); + } + + @WrapForJNI + private void populateNodeCollectionInfo( + final AccessibilityNodeInfo node, + final int rowCount, + final int columnCount, + final int selectionMode, + final boolean isHierarchical) { + final CollectionInfo collectionInfo = + Build.VERSION.SDK_INT >= 21 + ? CollectionInfo.obtain(rowCount, columnCount, isHierarchical, selectionMode) + : CollectionInfo.obtain(rowCount, columnCount, isHierarchical); + node.setCollectionInfo(collectionInfo); + } + + @WrapForJNI + private void populateNodeRangeInfo( + final AccessibilityNodeInfo node, + final int rangeType, + final float min, + final float max, + final float current) { + final RangeInfo rangeInfo = RangeInfo.obtain(rangeType, min, max, current); + node.setRangeInfo(rangeInfo); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java new file mode 100644 index 0000000000..2ed0b1a6c3 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionFinder.java @@ -0,0 +1,131 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.util.Pair; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Arrays; +import java.util.List; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoSession.FinderDisplayFlags; +import org.mozilla.geckoview.GeckoSession.FinderFindFlags; +import org.mozilla.geckoview.GeckoSession.FinderResult; + +/** + * {@code SessionFinder} instances returned by {@link GeckoSession#getFinder()} performs + * find-in-page operations. + */ +@AnyThread +public final class SessionFinder { + private static final String LOGTAG = "GeckoSessionFinder"; + + private static final List<Pair<Integer, String>> sFlagNames = + Arrays.asList( + new Pair<>(GeckoSession.FINDER_FIND_BACKWARDS, "backwards"), + new Pair<>(GeckoSession.FINDER_FIND_LINKS_ONLY, "linksOnly"), + new Pair<>(GeckoSession.FINDER_FIND_MATCH_CASE, "matchCase"), + new Pair<>(GeckoSession.FINDER_FIND_WHOLE_WORD, "wholeWord")); + + private static void addFlagsToBundle( + @FinderFindFlags final int flags, @NonNull final GeckoBundle bundle) { + for (final Pair<Integer, String> name : sFlagNames) { + if ((flags & name.first) != 0) { + bundle.putBoolean(name.second, true); + } + } + } + + /* package */ static int getFlagsFromBundle(@Nullable final GeckoBundle bundle) { + if (bundle == null) { + return 0; + } + + int flags = 0; + for (final Pair<Integer, String> name : sFlagNames) { + if (bundle.getBoolean(name.second)) { + flags |= name.first; + } + } + return flags; + } + + private final EventDispatcher mDispatcher; + @FinderDisplayFlags private int mDisplayFlags; + + /* package */ SessionFinder(@NonNull final EventDispatcher dispatcher) { + mDispatcher = dispatcher; + setDisplayFlags(0); + } + + /** + * Find and select a string on the current page, starting from the current selection or the start + * of the page if there is no selection. Optionally return results related to the search in a + * {@link FinderResult} object. If {@code searchString} is null, search is performed using the + * previous search string. + * + * @param searchString String to search, or null to find again using the previous string. + * @param flags Flags for performing the search; either 0 or a combination of {@link + * GeckoSession#FINDER_FIND_BACKWARDS FINDER_FIND_*} constants. + * @return Result of the search operation as a {@link GeckoResult} object. + * @see #clear + * @see #setDisplayFlags + */ + @NonNull + public GeckoResult<FinderResult> find( + @Nullable final String searchString, @FinderFindFlags final int flags) { + final GeckoBundle bundle = new GeckoBundle(sFlagNames.size() + 1); + bundle.putString("searchString", searchString); + addFlagsToBundle(flags, bundle); + + return mDispatcher + .queryBundle("GeckoView:FindInPage", bundle) + .map(response -> new FinderResult(response)); + } + + /** + * Clear any highlighted find-in-page matches. + * + * @see #find + * @see #setDisplayFlags + */ + public void clear() { + mDispatcher.dispatch("GeckoView:ClearMatches", null); + } + + /** + * Return flags for displaying find-in-page matches. + * + * @return Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL + * FINDER_DISPLAY_*} constants. + * @see #setDisplayFlags + * @see #find + */ + @FinderDisplayFlags + public int getDisplayFlags() { + return mDisplayFlags; + } + + /** + * Set flags for displaying find-in-page matches. + * + * @param flags Display flags as a combination of {@link GeckoSession#FINDER_DISPLAY_HIGHLIGHT_ALL + * FINDER_DISPLAY_*} constants. + * @see #getDisplayFlags + * @see #find + */ + public void setDisplayFlags(@FinderDisplayFlags final int flags) { + mDisplayFlags = flags; + + final GeckoBundle bundle = new GeckoBundle(3); + bundle.putBoolean("highlightAll", (flags & GeckoSession.FINDER_DISPLAY_HIGHLIGHT_ALL) != 0); + bundle.putBoolean("dimPage", (flags & GeckoSession.FINDER_DISPLAY_DIM_PAGE) != 0); + bundle.putBoolean("drawOutline", (flags & GeckoSession.FINDER_DISPLAY_DRAW_LINK_OUTLINE) != 0); + mDispatcher.dispatch("GeckoView:DisplayMatches", bundle); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java new file mode 100644 index 0000000000..f5e6c6976c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SessionTextInput.java @@ -0,0 +1,463 @@ +/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; indent-tabs-mode: nil; -*- + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.TargetApi; +import android.content.Context; +import android.graphics.RectF; +import android.os.Handler; +import android.text.Editable; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +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; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import org.mozilla.gecko.IGeckoEditableParent; +import org.mozilla.gecko.InputMethods; +import org.mozilla.gecko.NativeQueue; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * {@code SessionTextInput} handles text input for {@code GeckoSession} through key events or input + * methods. It is typically used to implement certain methods in {@link android.view.View} such as + * {@link android.view.View#onCreateInputConnection}, by forwarding such calls to corresponding + * methods in {@code SessionTextInput}. + * + * <p>For full functionality, {@code SessionTextInput} requires a {@link android.view.View} to be + * set first through {@link #setView}. When a {@link android.view.View} is not set or set to null, + * {@code SessionTextInput} will operate in a reduced functionality mode. See {@link + * #onCreateInputConnection} and methods in {@link GeckoSession.TextInputDelegate} for changes in + * behavior in this viewless mode. + */ +public final class SessionTextInput { + /* package */ static final String LOGTAG = "GeckoSessionTextInput"; + private static final boolean DEBUG = false; + + // Interface to access GeckoInputConnection from SessionTextInput. + /* package */ interface InputConnectionClient { + View getView(); + + Handler getHandler(Handler defHandler); + + InputConnection onCreateInputConnection(EditorInfo attrs); + } + + // Interface to access GeckoEditable from GeckoInputConnection. + /* package */ interface EditableClient { + // The following value is used by requestCursorUpdates + // ONE_SHOT calls updateCompositionRects() after getting current composing + // character rects. + @Retention(RetentionPolicy.SOURCE) + @IntDef({ONE_SHOT, START_MONITOR, END_MONITOR}) + /* package */ @interface CursorMonitorMode {} + + @WrapForJNI static final int ONE_SHOT = 1; + // START_MONITOR start the monitor for composing character rects. If is is + // updaed, call updateCompositionRects() + @WrapForJNI static final int START_MONITOR = 2; + // ENDT_MONITOR stops the monitor for composing character rects. + @WrapForJNI static final int END_MONITOR = 3; + + void sendKeyEvent(@Nullable View view, int action, @NonNull KeyEvent event); + + Editable getEditable(); + + void setBatchMode(boolean isBatchMode); + + Handler setInputConnectionHandler(@NonNull Handler handler); + + void postToInputConnection(@NonNull Runnable runnable); + + void requestCursorUpdates(@CursorMonitorMode int requestMode); + + void insertImage(@NonNull byte[] data, @NonNull String mimeType); + } + + // Interface to access GeckoInputConnection from GeckoEditable. + /* package */ interface EditableListener { + // IME notification type for notifyIME(), corresponding to NotificationToIME enum. + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + NOTIFY_IME_OF_TOKEN, + NOTIFY_IME_OPEN_VKB, + NOTIFY_IME_REPLY_EVENT, + NOTIFY_IME_OF_FOCUS, + NOTIFY_IME_OF_BLUR, + NOTIFY_IME_TO_COMMIT_COMPOSITION, + NOTIFY_IME_TO_CANCEL_COMPOSITION + }) + /* package */ @interface IMENotificationType {} + + @WrapForJNI static final int NOTIFY_IME_OF_TOKEN = -3; + @WrapForJNI static final int NOTIFY_IME_OPEN_VKB = -2; + @WrapForJNI static final int NOTIFY_IME_REPLY_EVENT = -1; + @WrapForJNI static final int NOTIFY_IME_OF_FOCUS = 1; + @WrapForJNI static final int NOTIFY_IME_OF_BLUR = 2; + @WrapForJNI static final int NOTIFY_IME_TO_COMMIT_COMPOSITION = 8; + @WrapForJNI static final int NOTIFY_IME_TO_CANCEL_COMPOSITION = 9; + + // IME enabled state for notifyIMEContext(). + @Retention(RetentionPolicy.SOURCE) + @IntDef({IME_STATE_UNKNOWN, IME_STATE_DISABLED, IME_STATE_ENABLED, IME_STATE_PASSWORD}) + /* package */ @interface IMEState {} + + static final int IME_STATE_UNKNOWN = -1; + static final int IME_STATE_DISABLED = 0; + static final int IME_STATE_ENABLED = 1; + static final int IME_STATE_PASSWORD = 2; + + // Flags for notifyIMEContext(). + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {IME_FLAG_PRIVATE_BROWSING, IME_FLAG_USER_ACTION, IME_FOCUS_NOT_CHANGED}) + /* package */ @interface IMEContextFlags {} + + @WrapForJNI static final int IME_FLAG_PRIVATE_BROWSING = 1 << 0; + @WrapForJNI static final int IME_FLAG_USER_ACTION = 1 << 1; + @WrapForJNI static final int IME_FOCUS_NOT_CHANGED = 1 << 2; + + void notifyIME(@IMENotificationType int type); + + void notifyIMEContext( + @IMEState int state, + String typeHint, + String modeHint, + String actionHint, + @IMEContextFlags int flag); + + void onSelectionChange(); + + void onTextChange(); + + void onDiscardComposition(); + + void onDefaultKeyEvent(KeyEvent event); + + void updateCompositionRects(final RectF[] aRects, final RectF aCaretRect); + } + + private static final class DefaultDelegate implements GeckoSession.TextInputDelegate { + public static final DefaultDelegate INSTANCE = new DefaultDelegate(); + + private InputMethodManager getInputMethodManager(@Nullable final View view) { + if (view == null) { + return null; + } + return (InputMethodManager) view.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + } + + @Override + public void restartInput(@NonNull final GeckoSession session, final int reason) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + + final InputMethodManager imm = getInputMethodManager(view); + if (imm == null) { + return; + } + + // 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( + InputMethods.getCurrentInputMethod(view.getContext()))) { + // 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 + imm.updateSelection(view, -1, -1, -1, -1); + } + + try { + imm.restartInput(view); + } catch (final RuntimeException e) { + Log.e(LOGTAG, "Error restarting input", e); + } + } + + @Override + public void showSoftInput(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + if (view.hasFocus() && !imm.isActive(view)) { + // Marshmallow workaround: The view has focus but it is not the active + // view for the input method. (Bug 1211848) + view.clearFocus(); + view.requestFocus(); + } + imm.showSoftInput(view, 0); + } + } + + @Override + public void hideSoftInput(@NonNull final GeckoSession session) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getWindowToken(), 0); + } + } + + @Override + public void updateSelection( + @NonNull final GeckoSession session, + final int selStart, + final int selEnd, + final int compositionStart, + final int compositionEnd) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + // When composition start and end is -1, + // InputMethodManager.updateSelection will remove composition + // on most IMEs. If not working, we have to add a workaround + // to EditableListener.onDiscardComposition. + imm.updateSelection(view, selStart, selEnd, compositionStart, compositionEnd); + } + } + + @Override + public void updateExtractedText( + @NonNull final GeckoSession session, + @NonNull final ExtractedTextRequest request, + @NonNull final ExtractedText text) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.updateExtractedText(view, request.token, text); + } + } + + @TargetApi(21) + @Override + public void updateCursorAnchorInfo( + @NonNull final GeckoSession session, @NonNull final CursorAnchorInfo info) { + ThreadUtils.assertOnUiThread(); + final View view = session.getTextInput().getView(); + final InputMethodManager imm = getInputMethodManager(view); + if (imm != null) { + imm.updateCursorAnchorInfo(view, info); + } + } + } + + private final GeckoSession mSession; + private final NativeQueue mQueue; + private final GeckoEditable mEditable; + private InputConnectionClient mInputConnection; + private GeckoSession.TextInputDelegate mDelegate; + + /* package */ SessionTextInput( + final @NonNull GeckoSession session, final @NonNull NativeQueue queue) { + mSession = session; + mQueue = queue; + mEditable = new GeckoEditable(session); + } + + /* package */ void onWindowChanged(final GeckoSession.Window window) { + if (mQueue.isReady()) { + window.attachEditable(mEditable); + } else { + mQueue.queueUntilReady(window, "attachEditable", IGeckoEditableParent.class, mEditable); + } + } + + /** + * Get a Handler for the background input method thread. In order to use a background thread for + * input method operations on systems prior to Nougat, first override {@code View.getHandler()} + * for the View returning the InputConnection instance, and then call this method from the + * overridden method. + * + * <p>For example: + * + * <pre> + * @Override + * public Handler getHandler() { + * if (Build.VERSION.SDK_INT >= 24) { + * return super.getHandler(); + * } + * return getSession().getTextInput().getHandler(super.getHandler()); + * }</pre> + * + * @param defHandler Handler returned by the system {@code getHandler} implementation. + * @return Handler to return to the system through {@code getHandler}. + */ + @AnyThread + public synchronized @NonNull Handler getHandler(final @NonNull Handler defHandler) { + // May be called on any thread. + if (mInputConnection != null) { + return mInputConnection.getHandler(defHandler); + } + return defHandler; + } + + /** + * Get the current {@link android.view.View} for text input. + * + * @return Current text input View or null if not set. + * @see #setView(View) + */ + @UiThread + public @Nullable View getView() { + ThreadUtils.assertOnUiThread(); + return mInputConnection != null ? mInputConnection.getView() : null; + } + + /** + * Set the current {@link android.view.View} for text input. The {@link android.view.View} is used + * to interact with the system input method manager and to display certain text input UI elements. + * See the {@code SessionTextInput} class documentation for information on viewless mode, when the + * current {@link android.view.View} is not set or set to null. + * + * @param view Text input View or null to clear current View. + * @see #getView() + */ + @UiThread + public synchronized void setView(final @Nullable View view) { + ThreadUtils.assertOnUiThread(); + + if (view == null) { + mInputConnection = null; + } else if (mInputConnection == null || mInputConnection.getView() != view) { + mInputConnection = GeckoInputConnection.create(mSession, view, mEditable); + } + mEditable.setListener((EditableListener) mInputConnection); + } + + /** + * Get an {@link android.view.inputmethod.InputConnection} instance. In viewless mode, this method + * still fills out the {@link android.view.inputmethod.EditorInfo} object, but the return value + * will always be null. + * + * @param attrs EditorInfo instance to be filled on return. + * @return InputConnection instance, or null if there is no active input (or if in viewless mode). + */ + @AnyThread + public synchronized @Nullable InputConnection onCreateInputConnection( + final @NonNull EditorInfo attrs) { + // May be called on any thread. + mEditable.onCreateInputConnection(attrs); + + if (!mQueue.isReady() || mInputConnection == null) { + return null; + } + return mInputConnection.onCreateInputConnection(attrs); + } + + /** + * Process a KeyEvent as a pre-IME event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyPreIme(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyPreIme(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a key-down event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyDown(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyDown(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a key-up event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyUp(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyUp(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a long-press event. + * + * @param keyCode Key code. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyLongPress(final int keyCode, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyLongPress(getView(), keyCode, event); + } + + /** + * Process a KeyEvent as a multiple-press event. + * + * @param keyCode Key code. + * @param repeatCount Key repeat count. + * @param event KeyEvent instance. + * @return True if the event was handled. + */ + @UiThread + public boolean onKeyMultiple( + final int keyCode, final int repeatCount, final @NonNull KeyEvent event) { + ThreadUtils.assertOnUiThread(); + return mEditable.onKeyMultiple(getView(), keyCode, repeatCount, event); + } + + /** + * Set the current text input delegate. + * + * @param delegate TextInputDelegate instance or null to restore to default. + */ + @UiThread + public void setDelegate(@Nullable final GeckoSession.TextInputDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Get the current text input delegate. + * + * @return TextInputDelegate instance or a default instance if no delegate has been set. + */ + @UiThread + public @NonNull GeckoSession.TextInputDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + if (mDelegate == null) { + mDelegate = DefaultDelegate.INSTANCE; + } + return mDelegate; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java new file mode 100644 index 0000000000..d25c51ef9a --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/SlowScriptResponse.java @@ -0,0 +1,20 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; + +/** + * Used by a ContentDelegate to indicate what action to take on a slow script event. + * + * @see GeckoSession.ContentDelegate#onSlowScript(GeckoSession,String) + */ +@AnyThread +public enum SlowScriptResponse { + STOP, + CONTINUE; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java new file mode 100644 index 0000000000..a49cdf26a5 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/StorageController.java @@ -0,0 +1,405 @@ +/* -*- 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.geckoview; + +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.math.BigInteger; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission; + +/** + * Manage runtime storage data. + * + * <p>Retrieve an instance via {@link GeckoRuntime#getStorageController}. + */ +public final class StorageController { + private static final String LOGTAG = "StorageController"; + + // Keep in sync with GeckoViewStorageController.ClearFlags. + /** Flags used for data clearing operations. */ + public static class ClearFlags { + /** Cookies. */ + public static final long COOKIES = 1 << 0; + + /** Network cache. */ + public static final long NETWORK_CACHE = 1 << 1; + + /** Image cache. */ + public static final long IMAGE_CACHE = 1 << 2; + + /** DOM storages. */ + public static final long DOM_STORAGES = 1 << 4; + + /** Auth tokens and caches. */ + public static final long AUTH_SESSIONS = 1 << 5; + + /** Site permissions. */ + public static final long PERMISSIONS = 1 << 6; + + /** All caches. */ + public static final long ALL_CACHES = NETWORK_CACHE | IMAGE_CACHE; + + /** All site settings (permissions, content preferences, security settings, etc.). */ + public static final long SITE_SETTINGS = 1 << 7 | PERMISSIONS; + + /** All site-related data (cookies, storages, caches, permissions, etc.). */ + public static final long SITE_DATA = + 1 << 8 | COOKIES | DOM_STORAGES | ALL_CACHES | PERMISSIONS | SITE_SETTINGS; + + /** All data. */ + public static final long ALL = 1 << 9; + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = { + ClearFlags.COOKIES, + ClearFlags.NETWORK_CACHE, + ClearFlags.IMAGE_CACHE, + ClearFlags.DOM_STORAGES, + ClearFlags.AUTH_SESSIONS, + ClearFlags.PERMISSIONS, + ClearFlags.ALL_CACHES, + ClearFlags.SITE_SETTINGS, + ClearFlags.SITE_DATA, + ClearFlags.ALL + }) + public @interface StorageControllerClearFlags {} + + /** + * Clear data for all hosts. + * + * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions prior to clearing data. + * + * @param flags Combination of {@link ClearFlags}. + * @return A {@link GeckoResult} that will complete when clearing has finished. + */ + @AnyThread + public @NonNull GeckoResult<Void> clearData(final @StorageControllerClearFlags long flags) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putLong("flags", flags); + + return EventDispatcher.getInstance().queryVoid("GeckoView:ClearData", bundle); + } + + /** + * Clear data owned by the given host. Clearing data for a host will not clear data created by its + * third-party origins. + * + * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions prior to clearing data. + * + * @param host The host to be used. + * @param flags Combination of {@link ClearFlags}. + * @return A {@link GeckoResult} that will complete when clearing has finished. + */ + @AnyThread + public @NonNull GeckoResult<Void> clearDataFromHost( + final @NonNull String host, final @StorageControllerClearFlags long flags) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("host", host); + bundle.putLong("flags", flags); + + return EventDispatcher.getInstance().queryVoid("GeckoView:ClearHostData", bundle); + } + + /** + * Clear data owned by the given base domain (eTLD+1). Clearing data for a base domain will also + * clear any associated third-party storage. This includes clearing for third-parties embedded by + * the domain and for the given domain embedded under other sites. + * + * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions prior to clearing data. + * + * @param baseDomain The base domain to be used. + * @param flags Combination of {@link ClearFlags}. + * @return A {@link GeckoResult} that will complete when clearing has finished. + */ + @AnyThread + public @NonNull GeckoResult<Void> clearDataFromBaseDomain( + final @NonNull String baseDomain, final @StorageControllerClearFlags long flags) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("baseDomain", baseDomain); + bundle.putLong("flags", flags); + + return EventDispatcher.getInstance().queryVoid("GeckoView:ClearBaseDomainData", bundle); + } + + /** + * Clear data for the given context ID. Use {@link GeckoSessionSettings.Builder#contextId}.to set + * a context ID for a session. + * + * <p>Note: Any open session may re-accumulate previously cleared data. To ensure that no + * persistent data is left behind, you need to close all sessions for the given context prior to + * clearing data. + * + * @param contextId The context ID for the storage data to be deleted. + */ + @AnyThread + public void clearDataForSessionContext(final @NonNull String contextId) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("contextId", createSafeSessionContextId(contextId)); + + EventDispatcher.getInstance().dispatch("GeckoView:ClearSessionContextData", bundle); + } + + /* package */ static @Nullable String createSafeSessionContextId( + final @Nullable String contextId) { + if (contextId == null) { + return null; + } + if (contextId.isEmpty()) { + // Let's avoid empty strings for Gecko. + return "gvctxempty"; + } + // We don't want to restrict the session context ID string options, so to + // ensure that the string is safe for Gecko processing, we translate it to + // its hex representation. + return String.format("gvctx%x", new BigInteger(contextId.getBytes())).toLowerCase(Locale.ROOT); + } + + /* package */ static @Nullable String retrieveUnsafeSessionContextId( + final @Nullable String contextId) { + if (contextId == null || contextId.isEmpty()) { + return null; + } + if ("gvctxempty".equals(contextId)) { + return ""; + } + final byte[] bytes = new BigInteger(contextId.substring(5), 16).toByteArray(); + return new String(bytes, Charset.forName("UTF-8")); + } + + /** + * Get all currently stored permissions. + * + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s. + */ + @AnyThread + public @NonNull GeckoResult<List<ContentPermission>> getAllPermissions() { + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetAllPermissions") + .map( + bundle -> { + final GeckoBundle[] permsArray = bundle.getBundleArray("permissions"); + return ContentPermission.fromBundleArray(permsArray); + }); + } + + /** + * Get all currently stored permissions for a given URI and default (unset) context ID, in normal + * mode This API will be deprecated in the future + * https://bugzilla.mozilla.org/show_bug.cgi?id=1797379 + * + * @param uri A String representing the URI to get permissions for. + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult<List<ContentPermission>> getPermissions(final @NonNull String uri) { + return getPermissions(uri, null, false); + } + + /** + * Get all currently stored permissions for a given URI and default (unset) context ID. + * + * @param uri A String representing the URI to get permissions for. + * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal + * mode. + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult<List<ContentPermission>> getPermissions( + final @NonNull String uri, final boolean privateMode) { + return getPermissions(uri, null, privateMode); + } + + /** + * Get all currently stored permissions for a given URI and context ID. + * + * @param uri A String representing the URI to get permissions for. + * @param contextId A String specifying the context ID. + * @param privateMode indicate where the {@link ContentPermission}s should be in private or normal + * mode + * @return A {@link GeckoResult} that will complete with a list of all currently stored {@link + * ContentPermission}s for the URI. + */ + @AnyThread + public @NonNull GeckoResult<List<ContentPermission>> getPermissions( + final @NonNull String uri, final @Nullable String contextId, final boolean privateMode) { + final GeckoBundle msg = new GeckoBundle(2); + final int privateBrowsingId = (privateMode) ? 1 : 0; + msg.putString("uri", uri); + msg.putString("contextId", createSafeSessionContextId(contextId)); + msg.putInt("privateBrowsingId", privateBrowsingId); + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetPermissionsByURI", msg) + .map( + bundle -> { + final GeckoBundle[] permsArray = bundle.getBundleArray("permissions"); + return ContentPermission.fromBundleArray(permsArray); + }); + } + + /** + * Set a new value for an existing permission. + * + * <p>Note: in private browsing, this value will only be cleared at the end of the session to add + * permanent permissions in private browsing, you can use {@link + * #setPrivateBrowsingPermanentPermission}. + * + * @param perm A {@link ContentPermission} that you wish to update the value of. + * @param value The new value for the permission. + */ + @AnyThread + public void setPermission( + final @NonNull ContentPermission perm, final @ContentPermission.Value int value) { + setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ false); + } + + /** + * Set a permanent value for a permission in a private browsing session. + * + * <p>Normally permissions in private browsing are cleared at the end of the session. This method + * allows you to set a permanent permission bypassing this behavior. + * + * <p>Note: permanent permissions in private browsing are web discoverable and might make the user + * more easily trackable. + * + * @see #setPermission + * @param perm A {@link ContentPermission} that you wish to update the value of. + * @param value The new value for the permission. + */ + @AnyThread + public void setPrivateBrowsingPermanentPermission( + final @NonNull ContentPermission perm, final @ContentPermission.Value int value) { + setPermissionInternal(perm, value, /* allowPermanentPrivateBrowsing */ true); + } + + private void setPermissionInternal( + final @NonNull ContentPermission perm, + final @ContentPermission.Value int value, + final boolean allowPermanentPrivateBrowsing) { + if (perm.permission == GeckoSession.PermissionDelegate.PERMISSION_TRACKING + && value == ContentPermission.VALUE_PROMPT) { + Log.w(LOGTAG, "Cannot set a tracking permission to VALUE_PROMPT, aborting."); + return; + } + final GeckoBundle msg = perm.toGeckoBundle(); + msg.putInt("newValue", value); + msg.putBoolean("allowPermanentPrivateBrowsing", allowPermanentPrivateBrowsing); + EventDispatcher.getInstance().dispatch("GeckoView:SetPermission", msg); + } + + /** + * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode. + * + * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode} + * value. + * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri. + * @param isPrivateBrowsing Indicates in which browsing mode the given {@link + * ContentBlocking.CBCookieBannerMode} should be applied. + * @return A {@link GeckoResult} that will complete when the mode has been set. + */ + @AnyThread + public @NonNull GeckoResult<Void> setCookieBannerModeForDomain( + final @NonNull String uri, + final @ContentBlocking.CBCookieBannerMode int mode, + final boolean isPrivateBrowsing) { + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putInt("mode", mode); + data.putBoolean("allowPermanentPrivateBrowsing", false); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data); + } + + /** + * Set a permanent {@link ContentBlocking.CBCookieBannerMode} for the given uri in private mode. + * + * @param uri for which you want to change the {@link ContentBlocking.CBCookieBannerMode} value. + * @param mode A new {@link ContentBlocking.CBCookieBannerMode} for the given uri. + * @return A {@link GeckoResult} that will complete when the mode has been set. + */ + @AnyThread + public @NonNull GeckoResult<Void> setCookieBannerModeAndPersistInPrivateBrowsingForDomain( + final @NonNull String uri, final @ContentBlocking.CBCookieBannerMode int mode) { + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putInt("mode", mode); + data.putBoolean("allowPermanentPrivateBrowsing", true); + return EventDispatcher.getInstance().queryVoid("GeckoView:SetCookieBannerModeForDomain", data); + } + + /** + * Removes a {@link ContentBlocking.CBCookieBannerMode} for the given uri and and browsing mode. + * + * @param uri An uri for which you want change the {@link ContentBlocking.CBCookieBannerMode} + * value. + * @param isPrivateBrowsing Indicates in which mode the given mode should be applied. + * @return A {@link GeckoResult} that will complete when the mode has been removed. + */ + @AnyThread + public @NonNull GeckoResult<Void> removeCookieBannerModeForDomain( + final @NonNull String uri, final boolean isPrivateBrowsing) { + + final GeckoBundle data = new GeckoBundle(3); + data.putString("uri", uri); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance() + .queryVoid("GeckoView:RemoveCookieBannerModeForDomain", data); + } + + /** + * Gets the actual {@link ContentBlocking.CBCookieBannerMode} for the given uri and browsing mode. + * + * @param uri An uri for which you want get the {@link ContentBlocking.CBCookieBannerMode}. + * @param isPrivateBrowsing Indicates in which browsing mode the given uri should be. + * @return A {@link GeckoResult} that resolves to a {@link ContentBlocking.CBCookieBannerMode} for + * the given uri and browsing mode. + */ + @AnyThread + public @NonNull @ContentBlocking.CBCookieBannerMode GeckoResult<Integer> + getCookieBannerModeForDomain(final @NonNull String uri, final boolean isPrivateBrowsing) { + + final GeckoBundle data = new GeckoBundle(2); + data.putString("uri", uri); + data.putBoolean("isPrivateBrowsing", isPrivateBrowsing); + return EventDispatcher.getInstance() + .queryBundle("GeckoView:GetCookieBannerModeForDomain", data) + .map(StorageController::cookieBannerModeFromBundle, StorageController::fromQueryException); + } + + private static @ContentBlocking.CBCookieBannerMode int cookieBannerModeFromBundle( + final GeckoBundle bundle) throws Exception { + if (bundle == null) { + throw new Exception("Unable to parse cookie banner mode"); + } + return bundle.getInt("mode"); + } + + private static Throwable fromQueryException(final Throwable exception) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) exception; + final Object response = queryException.data; + return new Exception(response.toString()); + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java new file mode 100644 index 0000000000..59324ddf91 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebAuthnTokenManager.java @@ -0,0 +1,572 @@ +/* -*- 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.geckoview; + +import android.app.PendingIntent; +import android.content.Intent; +import android.net.Uri; +import android.util.Base64; +import android.util.Log; +import com.google.android.gms.fido.Fido; +import com.google.android.gms.fido.common.Transport; +import com.google.android.gms.fido.fido2.Fido2ApiClient; +import com.google.android.gms.fido.fido2.Fido2PrivilegedApiClient; +import com.google.android.gms.fido.fido2.api.common.Algorithm; +import com.google.android.gms.fido.fido2.api.common.Attachment; +import com.google.android.gms.fido.fido2.api.common.AttestationConveyancePreference; +import com.google.android.gms.fido.fido2.api.common.AuthenticationExtensions; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorErrorResponse; +import com.google.android.gms.fido.fido2.api.common.AuthenticatorSelectionCriteria; +import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialCreationOptions; +import com.google.android.gms.fido.fido2.api.common.BrowserPublicKeyCredentialRequestOptions; +import com.google.android.gms.fido.fido2.api.common.EC2Algorithm; +import com.google.android.gms.fido.fido2.api.common.FidoAppIdExtension; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialDescriptor; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameters; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialType; +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity; +import com.google.android.gms.fido.fido2.api.common.RSAAlgorithm; +import com.google.android.gms.tasks.Task; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.List; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.GeckoBundle; + +/* package */ class WebAuthnTokenManager { + private static final String LOGTAG = "WebAuthnTokenManager"; + + // from u2fhid-capi.h + private static final byte AUTHENTICATOR_TRANSPORT_USB = 1; + private static final byte AUTHENTICATOR_TRANSPORT_NFC = 2; + private static final byte AUTHENTICATOR_TRANSPORT_BLE = 4; + + private static final Algorithm[] SUPPORTED_ALGORITHMS = { + EC2Algorithm.ES256, + EC2Algorithm.ES384, + EC2Algorithm.ES512, + EC2Algorithm.ED256, /* no ED384 */ + EC2Algorithm.ED512, + RSAAlgorithm.PS256, + RSAAlgorithm.PS384, + RSAAlgorithm.PS512, + RSAAlgorithm.RS256, + RSAAlgorithm.RS384, + RSAAlgorithm.RS512 + }; + + private static List<Transport> getTransportsForByte(final byte transports) { + final ArrayList<Transport> result = new ArrayList<Transport>(); + if ((transports & AUTHENTICATOR_TRANSPORT_USB) == AUTHENTICATOR_TRANSPORT_USB) { + result.add(Transport.USB); + } + if ((transports & AUTHENTICATOR_TRANSPORT_NFC) == AUTHENTICATOR_TRANSPORT_NFC) { + result.add(Transport.NFC); + } + if ((transports & AUTHENTICATOR_TRANSPORT_BLE) == AUTHENTICATOR_TRANSPORT_BLE) { + result.add(Transport.BLUETOOTH_LOW_ENERGY); + } + + return result; + } + + public static class WebAuthnPublicCredential { + public final byte[] id; + public final byte transports; + + public WebAuthnPublicCredential(final byte[] aId, final byte aTransports) { + this.id = aId; + this.transports = aTransports; + } + + static ArrayList<WebAuthnPublicCredential> CombineBuffers( + final Object[] idObjectList, final ByteBuffer transportList) { + if (idObjectList.length != transportList.remaining()) { + throw new RuntimeException("Couldn't extract allowed list!"); + } + + final ArrayList<WebAuthnPublicCredential> credList = + new ArrayList<WebAuthnPublicCredential>(); + + final byte[] transportBytes = new byte[transportList.remaining()]; + transportList.get(transportBytes); + + for (int i = 0; i < idObjectList.length; i++) { + final ByteBuffer id = (ByteBuffer) idObjectList[i]; + final byte[] idBytes = new byte[id.remaining()]; + id.get(idBytes); + + credList.add(new WebAuthnPublicCredential(idBytes, transportBytes[i])); + } + return credList; + } + } + + // From WebAuthentication.webidl + public enum AttestationPreference { + NONE, + INDIRECT, + DIRECT, + } + + @WrapForJNI + public static class MakeCredentialResponse { + public final byte[] clientDataJson; + public final byte[] keyHandle; + public final byte[] attestationObject; + + public MakeCredentialResponse( + final byte[] clientDataJson, final byte[] keyHandle, final byte[] attestationObject) { + this.clientDataJson = clientDataJson; + this.keyHandle = keyHandle; + this.attestationObject = attestationObject; + } + } + + public static class Exception extends RuntimeException { + public Exception(final String error) { + super(error); + } + } + + public static GeckoResult<MakeCredentialResponse> makeCredential( + final GeckoBundle credentialBundle, + final byte[] userId, + final byte[] challenge, + final WebAuthnTokenManager.WebAuthnPublicCredential[] excludeList, + final GeckoBundle authenticatorSelection, + final GeckoBundle extensions) { + if (!credentialBundle.containsKey("isWebAuthn")) { + // FIDO U2F not supported by Android (for us anyway) at this time + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR")); + } + + final PublicKeyCredentialCreationOptions.Builder requestBuilder = + new PublicKeyCredentialCreationOptions.Builder(); + + final List<PublicKeyCredentialParameters> params = + new ArrayList<PublicKeyCredentialParameters>(); + + // WebAuthn supports more algorithms + for (final Algorithm algo : SUPPORTED_ALGORITHMS) { + params.add( + new PublicKeyCredentialParameters( + PublicKeyCredentialType.PUBLIC_KEY.toString(), algo.getAlgoValue())); + } + + final PublicKeyCredentialUserEntity user = + new PublicKeyCredentialUserEntity( + userId, + credentialBundle.getString("userName", ""), + credentialBundle.getString("userIcon", ""), + credentialBundle.getString("userDisplayName", "")); + + AttestationConveyancePreference pref = AttestationConveyancePreference.NONE; + final String attestationPreference = + authenticatorSelection.getString("attestationPreference", "NONE"); + if (attestationPreference.equalsIgnoreCase(AttestationConveyancePreference.DIRECT.name())) { + pref = AttestationConveyancePreference.DIRECT; + } else if (attestationPreference.equalsIgnoreCase( + AttestationConveyancePreference.INDIRECT.name())) { + pref = AttestationConveyancePreference.INDIRECT; + } + + final AuthenticatorSelectionCriteria.Builder selBuild = + new AuthenticatorSelectionCriteria.Builder(); + if (authenticatorSelection.getInt("requirePlatformAttachment", 0) == 1) { + selBuild.setAttachment(Attachment.PLATFORM); + } + if (authenticatorSelection.getInt("requireCrossPlatformAttachment", 0) == 1) { + selBuild.setAttachment(Attachment.CROSS_PLATFORM); + } + final AuthenticatorSelectionCriteria sel = selBuild.build(); + + final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder(); + if (extensions.containsKey("fidoAppId")) { + extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId"))); + } + final AuthenticationExtensions ext = extBuilder.build(); + + // requireResidentKey andrequireUserVerification are not yet + // consumed by Android's API + + final List<PublicKeyCredentialDescriptor> excludedList = + new ArrayList<PublicKeyCredentialDescriptor>(); + for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : excludeList) { + excludedList.add( + new PublicKeyCredentialDescriptor( + PublicKeyCredentialType.PUBLIC_KEY.toString(), + cred.id, + getTransportsForByte(cred.transports))); + } + + final PublicKeyCredentialRpEntity rp = + new PublicKeyCredentialRpEntity( + credentialBundle.getString("rpId"), + credentialBundle.getString("rpName", ""), + credentialBundle.getString("rpIcon", "")); + + final PublicKeyCredentialCreationOptions requestOptions = + requestBuilder + .setUser(user) + .setAttestationConveyancePreference(pref) + .setAuthenticatorSelection(sel) + .setAuthenticationExtensions(ext) + .setChallenge(challenge) + .setRp(rp) + .setParameters(params) + .setTimeoutSeconds(credentialBundle.getLong("timeoutMS") / 1000.0) + .setExcludeList(excludedList) + .build(); + + final Uri origin = Uri.parse(credentialBundle.getString("origin")); + + final BrowserPublicKeyCredentialCreationOptions browserOptions = + new BrowserPublicKeyCredentialCreationOptions.Builder() + .setPublicKeyCredentialCreationOptions(requestOptions) + .setOrigin(origin) + .build(); + + final Task<PendingIntent> intentTask; + + if (BuildConfig.MOZILLA_OFFICIAL) { + // Certain Fenix builds and signing keys are whitelisted for Web Authentication. + // See https://wiki.mozilla.org/Security/Web_Authentication + // + // Third party apps will need to get whitelisted themselves. + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getRegisterPendingIntent(browserOptions); + } else { + // For non-official builds, websites have to opt-in to permit the + // particular version of Gecko to perform WebAuthn operations on + // them. See https://developers.google.com/digital-asset-links + // for the general form, and Step 1 of + // https://developers.google.com/identity/fido/android/native-apps + // for details about doing this correctly for the FIDO2 API. + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getRegisterPendingIntent(requestOptions); + } + + final GeckoResult<MakeCredentialResponse> result = new GeckoResult<>(); + + intentTask.addOnSuccessListener( + pendingIntent -> { + GeckoRuntime.getInstance() + .startActivityForResult(pendingIntent) + .accept( + intent -> { + final WebAuthnTokenManager.Exception error = parseErrorIntent(intent); + if (error != null) { + result.completeExceptionally(error); + return; + } + + final byte[] rspData = intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA); + if (rspData != null) { + final AuthenticatorAttestationResponse responseData = + AuthenticatorAttestationResponse.deserializeFromBytes(rspData); + + Log.d( + LOGTAG, + "key handle: " + + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "clientDataJSON: " + + Base64.encodeToString( + responseData.getClientDataJSON(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "attestation Object: " + + Base64.encodeToString( + responseData.getAttestationObject(), Base64.DEFAULT)); + + result.complete( + new WebAuthnTokenManager.MakeCredentialResponse( + responseData.getClientDataJSON(), + responseData.getKeyHandle(), + responseData.getAttestationObject())); + } + }, + e -> { + Log.w(LOGTAG, "Failed to launch activity: ", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR")); + }); + }); + + intentTask.addOnFailureListener( + e -> { + Log.w(LOGTAG, "Failed to get FIDO intent", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("ABORT_ERR")); + }); + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult<MakeCredentialResponse> webAuthnMakeCredential( + final GeckoBundle credentialBundle, + final ByteBuffer userId, + final ByteBuffer challenge, + final Object[] idList, + final ByteBuffer transportList, + final GeckoBundle authenticatorSelection, + final GeckoBundle extensions) { + final ArrayList<WebAuthnPublicCredential> excludeList; + + final byte[] challBytes = new byte[challenge.remaining()]; + final byte[] userBytes = new byte[userId.remaining()]; + try { + challenge.get(challBytes); + userId.get(userBytes); + + excludeList = WebAuthnPublicCredential.CombineBuffers(idList, transportList); + } catch (final RuntimeException e) { + Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + + try { + return makeCredential( + credentialBundle, + userBytes, + challBytes, + excludeList.toArray(new WebAuthnPublicCredential[0]), + authenticatorSelection, + extensions); + } catch (final Exception e) { + // We need to ensure we catch any possible exception here in order to ensure + // that the Promise on the content side is appropriately rejected. In particular, + // we will get `NoClassDefFoundError` if we're running on a device that does not + // have Google Play Services. + Log.w(LOGTAG, "Couldn't make credential", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + } + + @WrapForJNI + public static class GetAssertionResponse { + public final byte[] clientDataJson; + public final byte[] keyHandle; + public final byte[] authData; + public final byte[] signature; + public final byte[] userHandle; + + public GetAssertionResponse( + final byte[] clientDataJson, + final byte[] keyHandle, + final byte[] authData, + final byte[] signature, + final byte[] userHandle) { + this.clientDataJson = clientDataJson; + this.keyHandle = keyHandle; + this.authData = authData; + this.signature = signature; + this.userHandle = userHandle; + } + } + + private static WebAuthnTokenManager.Exception parseErrorIntent(final Intent intent) { + if (!intent.hasExtra(Fido.FIDO2_KEY_ERROR_EXTRA)) { + return null; + } + + final byte[] errData = intent.getByteArrayExtra(Fido.FIDO2_KEY_ERROR_EXTRA); + final AuthenticatorErrorResponse responseData = + AuthenticatorErrorResponse.deserializeFromBytes(errData); + + Log.e(LOGTAG, "errorCode.name: " + responseData.getErrorCode()); + Log.e(LOGTAG, "errorMessage: " + responseData.getErrorMessage()); + + return new WebAuthnTokenManager.Exception(responseData.getErrorCode().name()); + } + + private static GeckoResult<GetAssertionResponse> getAssertion( + final byte[] challenge, + final WebAuthnTokenManager.WebAuthnPublicCredential[] allowList, + final GeckoBundle assertionBundle, + final GeckoBundle extensions) { + + if (!assertionBundle.containsKey("isWebAuthn")) { + // FIDO U2F not supported by Android (for us anyway) at this time + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("NOT_SUPPORTED_ERR")); + } + + final List<PublicKeyCredentialDescriptor> allowedList = + new ArrayList<PublicKeyCredentialDescriptor>(); + for (final WebAuthnTokenManager.WebAuthnPublicCredential cred : allowList) { + allowedList.add( + new PublicKeyCredentialDescriptor( + PublicKeyCredentialType.PUBLIC_KEY.toString(), + cred.id, + getTransportsForByte(cred.transports))); + } + + final AuthenticationExtensions.Builder extBuilder = new AuthenticationExtensions.Builder(); + if (extensions.containsKey("fidoAppId")) { + extBuilder.setFido2Extension(new FidoAppIdExtension(extensions.getString("fidoAppId"))); + } + final AuthenticationExtensions ext = extBuilder.build(); + + final PublicKeyCredentialRequestOptions requestOptions = + new PublicKeyCredentialRequestOptions.Builder() + .setChallenge(challenge) + .setAllowList(allowedList) + .setTimeoutSeconds(assertionBundle.getLong("timeoutMS") / 1000.0) + .setRpId(assertionBundle.getString("rpId")) + .setAuthenticationExtensions(ext) + .build(); + + final Uri origin = Uri.parse(assertionBundle.getString("origin")); + final BrowserPublicKeyCredentialRequestOptions browserOptions = + new BrowserPublicKeyCredentialRequestOptions.Builder() + .setPublicKeyCredentialRequestOptions(requestOptions) + .setOrigin(origin) + .build(); + + final Task<PendingIntent> intentTask; + // See the makeCredential method for documentation about this + // conditional. + if (BuildConfig.MOZILLA_OFFICIAL) { + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getSignPendingIntent(browserOptions); + } else { + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + + intentTask = fidoClient.getSignPendingIntent(requestOptions); + } + + final GeckoResult<GetAssertionResponse> result = new GeckoResult<>(); + intentTask.addOnSuccessListener( + pendingIntent -> { + GeckoRuntime.getInstance() + .startActivityForResult(pendingIntent) + .accept( + intent -> { + final WebAuthnTokenManager.Exception error = parseErrorIntent(intent); + if (error != null) { + result.completeExceptionally(error); + return; + } + + if (intent.hasExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA)) { + final byte[] rspData = + intent.getByteArrayExtra(Fido.FIDO2_KEY_RESPONSE_EXTRA); + final AuthenticatorAssertionResponse responseData = + AuthenticatorAssertionResponse.deserializeFromBytes(rspData); + + Log.d( + LOGTAG, + "key handle: " + + Base64.encodeToString(responseData.getKeyHandle(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "clientDataJSON: " + + Base64.encodeToString( + responseData.getClientDataJSON(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "auth data: " + + Base64.encodeToString( + responseData.getAuthenticatorData(), Base64.DEFAULT)); + Log.d( + LOGTAG, + "signature: " + + Base64.encodeToString(responseData.getSignature(), Base64.DEFAULT)); + + // Nullable field + byte[] userHandle = responseData.getUserHandle(); + if (userHandle == null) { + userHandle = new byte[0]; + } + + result.complete( + new WebAuthnTokenManager.GetAssertionResponse( + responseData.getClientDataJSON(), + responseData.getKeyHandle(), + responseData.getAuthenticatorData(), + responseData.getSignature(), + userHandle)); + } + }, + e -> { + Log.w(LOGTAG, "Failed to get FIDO intent", e); + result.completeExceptionally(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + }); + }); + + return result; + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult<GetAssertionResponse> webAuthnGetAssertion( + final ByteBuffer challenge, + final Object[] idList, + final ByteBuffer transportList, + final GeckoBundle assertionBundle, + final GeckoBundle extensions) { + final ArrayList<WebAuthnPublicCredential> allowList; + + final byte[] challBytes = new byte[challenge.remaining()]; + try { + challenge.get(challBytes); + allowList = WebAuthnPublicCredential.CombineBuffers(idList, transportList); + } catch (final RuntimeException e) { + Log.w(LOGTAG, "Couldn't extract nio byte arrays!", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + + try { + return getAssertion( + challBytes, + allowList.toArray(new WebAuthnPublicCredential[0]), + assertionBundle, + extensions); + } catch (final java.lang.Exception e) { + Log.w(LOGTAG, "Couldn't get assertion", e); + return GeckoResult.fromException(new WebAuthnTokenManager.Exception("UNKNOWN_ERR")); + } + } + + @WrapForJNI(calledFrom = "gecko") + private static GeckoResult<Boolean> webAuthnIsUserVerifyingPlatformAuthenticatorAvailable() { + final Task<Boolean> task; + if (BuildConfig.MOZILLA_OFFICIAL) { + final Fido2PrivilegedApiClient fidoClient = + Fido.getFido2PrivilegedApiClient(GeckoAppShell.getApplicationContext()); + task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable(); + } else { + final Fido2ApiClient fidoClient = + Fido.getFido2ApiClient(GeckoAppShell.getApplicationContext()); + task = fidoClient.isUserVerifyingPlatformAuthenticatorAvailable(); + } + + final GeckoResult<Boolean> res = new GeckoResult<>(); + task.addOnSuccessListener( + isUVPAA -> { + res.complete(isUVPAA); + }); + task.addOnFailureListener( + e -> { + Log.w(LOGTAG, "isUserVerifyingPlatformAuthenticatorAvailable is failed", e); + res.complete(false); + }); + return res; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java new file mode 100644 index 0000000000..a4248142a4 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtension.java @@ -0,0 +1,2750 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.graphics.Color; +import android.util.Log; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.LongDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import org.json.JSONException; +import org.json.JSONObject; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +/** Represents a WebExtension that may be used by GeckoView. */ +public class WebExtension { + /** + * <code>file:</code> or <code>resource:</code> URI that points to the install location of this + * WebExtension. When the WebExtension is included with the APK the file can be specified using + * the <code>resource://android</code> alias. E.g. + * + * <pre><code> + * resource://android/assets/web_extensions/my_webextension/ + * </code></pre> + * + * Will point to folder <code>/assets/web_extensions/my_webextension/</code> in the APK. + */ + public final @NonNull String location; + /** Unique identifier for this WebExtension */ + public final @NonNull String id; + /** {@link Flags} for this WebExtension. */ + public final @WebExtensionFlags long flags; + + /** Provides information about this {@link WebExtension}. */ + public final @NonNull MetaData metaData; + + /** + * Whether this extension is built-in. Built-in extension can be installed using {@link + * WebExtensionController#installBuiltIn}. + */ + public final boolean isBuiltIn; + + /** + * Called whenever a delegate is set or unset on this {@link WebExtension} instance. /* package + */ + interface DelegateController { + void onMessageDelegate(final String nativeApp, final MessageDelegate delegate); + + void onActionDelegate(final ActionDelegate delegate); + + void onBrowsingDataDelegate(final BrowsingDataDelegate delegate); + + void onTabDelegate(final TabDelegate delegate); + + void onDownloadDelegate(final DownloadDelegate delegate); + + ActionDelegate getActionDelegate(); + + BrowsingDataDelegate getBrowsingDataDelegate(); + + TabDelegate getTabDelegate(); + + DownloadDelegate getDownloadDelegate(); + } + + /* package */ interface DelegateControllerProvider { + @NonNull + DelegateController controllerFor(final WebExtension extension); + } + + private final DelegateController mDelegateController; + + @Override + public String toString() { + return "WebExtension {" + + "location=" + + location + + ", " + + "id=" + + id + + ", " + + "flags=" + + flags + + "}"; + } + + private static final String LOGTAG = "WebExtension"; + + // Keep in sync with GeckoViewWebExtension.jsm + public static class Flags { + /* + * Default flags for this WebExtension. + */ + public static final long NONE = 0; + /** + * Set this flag if you want to enable content scripts messaging. To listen to such messages you + * can use {@link SessionController#setMessageDelegate}. + */ + public static final long ALLOW_CONTENT_MESSAGING = 1 << 0; + + // Do not instantiate this class. + protected Flags() {} + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + flag = true, + value = {Flags.NONE, Flags.ALLOW_CONTENT_MESSAGING}) + public @interface WebExtensionFlags {} + + /* package */ WebExtension(final DelegateControllerProvider provider, final GeckoBundle bundle) { + location = bundle.getString("locationURI"); + id = bundle.getString("webExtensionId"); + flags = bundle.getInt("webExtensionFlags", 0); + isBuiltIn = bundle.getBoolean("isBuiltIn", false); + if (bundle.containsKey("metaData")) { + metaData = new MetaData(bundle.getBundle("metaData")); + } else { + metaData = null; + } + mDelegateController = provider.controllerFor(this); + } + + /** + * Defines the message delegate for a Native App. + * + * <p>This message delegate will receive messages from the background script for the native app + * specified in <code>nativeApp</code>. + * + * <p>For messages from content scripts, set a session-specific message delegate using {@link + * SessionController#setMessageDelegate}. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging"> + * WebExtensions/Native_messaging </a> + * + * @param messageDelegate handles messaging between the WebExtension and the app. To send a + * message from the WebExtension use the <code>runtime.sendNativeMessage</code> WebExtension + * API: E.g. + * <pre><code> + * browser.runtime.sendNativeMessage(nativeApp, + * {message: "Hello from WebExtension!"}); + * </code></pre> + * For bidirectional communication, use <code>runtime.connectNative</code>. E.g. in a content + * script: + * <pre><code> + * let port = browser.runtime.connectNative(nativeApp); + * port.onMessage.addListener(message => { + * console.log("Message received from app"); + * }); + * port.postMessage("Ping from WebExtension"); + * </code></pre> + * The code above will trigger a {@link MessageDelegate#onConnect} call that will contain the + * corresponding {@link Port} object that can be used to send messages to the WebExtension. + * Note: the <code>nativeApp</code> specified in the WebExtension needs to match the <code> + * nativeApp</code> parameter of this method. + * <p>You can unset the message delegate by setting a <code>null</code> messageDelegate. + * @param nativeApp which native app id this message delegate will handle messaging for. Needs to + * match the <code>application</code> parameter of <code>runtime.sendNativeMessage</code> and + * <code>runtime.connectNative</code>. + * @see SessionController#setMessageDelegate + */ + @UiThread + public void setMessageDelegate( + final @Nullable MessageDelegate messageDelegate, final @NonNull String nativeApp) { + mDelegateController.onMessageDelegate(nativeApp, messageDelegate); + } + + @Retention(RetentionPolicy.SOURCE) + @LongDef( + value = { + BrowsingDataDelegate.Type.CACHE, + BrowsingDataDelegate.Type.COOKIES, + BrowsingDataDelegate.Type.DOWNLOADS, + BrowsingDataDelegate.Type.FORM_DATA, + BrowsingDataDelegate.Type.HISTORY, + BrowsingDataDelegate.Type.LOCAL_STORAGE, + BrowsingDataDelegate.Type.PASSWORDS + }, + flag = true) + public @interface BrowsingDataTypes {} + + /** + * This delegate is used to handle calls from the |browsingData| WebExtension API. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData"> + * WebExtensions/API/browsingData </a> + */ + @UiThread + public interface BrowsingDataDelegate { + /** + * This class represents the current default settings for the "Clear Data" functionality in the + * browser. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browsingData/settings"> + * WebExtensions/API/browsingData/settings </a> + */ + @UiThread + class Settings { + /** + * Currently selected setting in the browser's "Clear Data" UI for how far back in time to + * remove data given in milliseconds since the UNIX epoch. + */ + public final int sinceUnixTimestamp; + /** + * Data types that can be toggled in the browser's "Clear Data" UI. One or more flags from + * {@link Type}. + */ + public final @BrowsingDataTypes long toggleableTypes; + + /** + * Data types currently selected in the browser's "Clear Data" UI. One or more flags from + * {@link Type}. + */ + public final @BrowsingDataTypes long selectedTypes; + + /** + * Creates an instance of Settings. + * + * <p>This class represents the current default settings for the "Clear Data" functionality in + * the browser. + * + * @param since Currently selected setting in the browser's "Clear Data" UI for how far back + * in time to remove data given in milliseconds since the UNIX epoch. + * @param toggleableTypes Data types that can be toggled in the browser's "Clear Data" UI. One + * or more flags from {@link Type}. + * @param selectedTypes Data types currently selected in the browser's "Clear Data" UI. One or + * more flags from {@link Type}. + */ + @UiThread + public Settings( + final int since, + final @BrowsingDataTypes long toggleableTypes, + final @BrowsingDataTypes long selectedTypes) { + this.toggleableTypes = toggleableTypes; + this.selectedTypes = selectedTypes; + this.sinceUnixTimestamp = since; + } + + private GeckoBundle fromBrowsingDataType(final @BrowsingDataTypes long types) { + final GeckoBundle result = new GeckoBundle(7); + result.putBoolean("cache", (types & Type.CACHE) != 0); + result.putBoolean("cookies", (types & Type.COOKIES) != 0); + result.putBoolean("downloads", (types & Type.DOWNLOADS) != 0); + result.putBoolean("formData", (types & Type.FORM_DATA) != 0); + result.putBoolean("history", (types & Type.HISTORY) != 0); + result.putBoolean("localStorage", (types & Type.LOCAL_STORAGE) != 0); + result.putBoolean("passwords", (types & Type.PASSWORDS) != 0); + return result; + } + + /* package */ GeckoBundle toGeckoBundle() { + final GeckoBundle options = new GeckoBundle(1); + options.putLong("since", sinceUnixTimestamp); + + final GeckoBundle result = new GeckoBundle(3); + result.putBundle("options", options); + result.putBundle("dataToRemove", fromBrowsingDataType(selectedTypes)); + result.putBundle("dataRemovalPermitted", fromBrowsingDataType(toggleableTypes)); + return result; + } + } + + /** Types of data that a browser "Clear Data" UI might have access to. */ + class Type { + protected Type() {} + + public static final long CACHE = 1 << 0; + public static final long COOKIES = 1 << 1; + public static final long DOWNLOADS = 1 << 2; + public static final long FORM_DATA = 1 << 3; + public static final long HISTORY = 1 << 4; + public static final long LOCAL_STORAGE = 1 << 5; + public static final long PASSWORDS = 1 << 6; + } + + /** + * Gets current settings for the browser's "Clear Data" UI. + * + * @return a {@link GeckoResult} that resolves to an instance of {@link Settings} that + * represents the current state for the browser's "Clear Data" UI. + * @see Settings + */ + @Nullable + default GeckoResult<Settings> onGetSettings() { + return null; + } + + /** + * Clear form data created after the given timestamp. + * + * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. + * @return a {@link GeckoResult} that resolves when data has been cleared. + */ + @Nullable + default GeckoResult<Void> onClearFormData(final long sinceUnixTimestamp) { + return null; + } + + /** + * Clear passwords saved after the given timestamp. + * + * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. + * @return a {@link GeckoResult} that resolves when data has been cleared. + */ + @Nullable + default GeckoResult<Void> onClearPasswords(final long sinceUnixTimestamp) { + return null; + } + + /** + * Clear history saved after the given timestamp. + * + * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. + * @return a {@link GeckoResult} that resolves when data has been cleared. + */ + @Nullable + default GeckoResult<Void> onClearHistory(final long sinceUnixTimestamp) { + return null; + } + + /** + * Clear downloads created after the given timestamp. + * + * @param sinceUnixTimestamp timestamp in seconds since the UNIX Epoch. + * @return a {@link GeckoResult} that resolves when data has been cleared. + */ + @Nullable + default GeckoResult<Void> onClearDownloads(final long sinceUnixTimestamp) { + return null; + } + } + + /** Delegates that responds to messages sent from a WebExtension. */ + @UiThread + public interface MessageDelegate { + /** + * Called whenever the WebExtension sends a message to an app using <code> + * runtime.sendNativeMessage</code>. + * + * @param nativeApp The application identifier of the MessageDelegate that sent this message. + * @param message The message that was sent, either a primitive type or a {@link + * org.json.JSONObject}. + * @param sender The {@link MessageSender} corresponding to the frame that originated the + * message. + * <p>Note: all messages are to be considered untrusted and should be checked carefully for + * validity. + * @return A {@link GeckoResult} that resolves with a response to the message. + */ + @Nullable + default GeckoResult<Object> onMessage( + final @NonNull String nativeApp, + final @NonNull Object message, + final @NonNull MessageSender sender) { + return null; + } + + /** + * Called whenever the WebExtension connects to an app using <code>runtime.connectNative</code>. + * + * @param port {@link Port} instance that can be used to send and receive messages from the + * WebExtension. Use {@link Port#sender} to verify the origin of this connection request. + */ + @Nullable + default void onConnect(final @NonNull Port port) {} + } + + /** + * Delegate that handles communication from a WebExtension on a specific {@link Port} instance. + */ + @UiThread + public interface PortDelegate { + /** + * Called whenever a message is sent through the corresponding {@link Port} instance. + * + * @param message The message that was sent, either a primitive type or a {@link + * org.json.JSONObject}. + * @param port The {@link Port} instance that received this message. + */ + default void onPortMessage(final @NonNull Object message, final @NonNull Port port) {} + + /** + * Called whenever the corresponding {@link Port} instance is disconnected or the corresponding + * {@link GeckoSession} is destroyed. Any message sent from the port after this call will be + * ignored. + * + * @param port The {@link Port} instance that was disconnected. + */ + @NonNull + default void onDisconnect(final @NonNull Port port) {} + } + + /** + * Port object that can be used for bidirectional communication with a WebExtension. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/Port"> + * WebExtensions/API/runtime/Port </a>. + * + * @see MessageDelegate#onConnect + */ + @UiThread + public static class Port { + /* package */ final long id; + /* package */ PortDelegate delegate; + /* package */ boolean disconnected = false; + /* package */ final EventDispatcher mEventDispatcher; + /* package */ boolean mListenersRegistered = false; + + /** {@link MessageSender} corresponding to this port. */ + public @NonNull final MessageSender sender; + + /** The application identifier of the MessageDelegate that opened this port. */ + public @NonNull final String name; + + /** Override for tests. */ + protected Port() { + this.id = -1; + this.delegate = null; + this.sender = null; + this.name = null; + mEventDispatcher = null; + } + + /* package */ Port(final String name, final long id, final MessageSender sender) { + this.id = id; + this.delegate = null; + this.sender = sender; + this.name = name; + mEventDispatcher = EventDispatcher.byName("port:" + id); + } + + private BundleEventListener mEventListener = + new BundleEventListener() { + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if ("GeckoView:WebExtension:Disconnect".equals(event)) { + disconnectFromExtension(callback); + } else if ("GeckoView:WebExtension:PortMessage".equals(event)) { + portMessage(message, callback); + } + } + }; + + private void disconnectFromExtension(final EventCallback callback) { + delegate.onDisconnect(this); + disconnected(); + } + + private void portMessage(final GeckoBundle bundle, final EventCallback callback) { + final Object content; + try { + content = bundle.toJSONObject().get("data"); + } catch (final JSONException ex) { + callback.sendError(ex); + return; + } + + delegate.onPortMessage(content, this); + } + + /** + * Post a message to the WebExtension connected to this {@link Port} instance. + * + * @param message {@link JSONObject} that will be sent to the WebExtension. + */ + public void postMessage(final @NonNull JSONObject message) { + final GeckoBundle args = new GeckoBundle(1); + try { + args.putBundle("message", GeckoBundle.fromJSONObject(message)); + } catch (final JSONException ex) { + throw new RuntimeException(ex); + } + + mEventDispatcher.dispatch("GeckoView:WebExtension:PortMessageFromApp", args); + } + + /** Disconnects this port and notifies the other end. */ + public void disconnect() { + if (this.disconnected) { + return; + } + + final GeckoBundle args = new GeckoBundle(1); + args.putLong("portId", id); + + mEventDispatcher.dispatch("GeckoView:WebExtension:PortDisconnect", args); + disconnected(); + } + + private void disconnected() { + unregisterListeners(); + mEventDispatcher.shutdown(); + this.disconnected = true; + } + + /** + * Set a delegate for incoming messages through this {@link Port}. + * + * @param delegate Delegate that will receive messages sent through this {@link Port}. + */ + public void setDelegate(final @Nullable PortDelegate delegate) { + this.delegate = delegate; + + if (delegate != null) { + registerListeners(); + } else { + unregisterListeners(); + } + } + + private void unregisterListeners() { + if (!mListenersRegistered) { + return; + } + + mEventDispatcher.unregisterUiThreadListener( + mEventListener, + "GeckoView:WebExtension:Disconnect", + "GeckoView:WebExtension:PortMessage"); + mListenersRegistered = false; + } + + private void registerListeners() { + if (mListenersRegistered) { + return; + } + + mEventDispatcher.registerUiThreadListener( + mEventListener, + "GeckoView:WebExtension:Disconnect", + "GeckoView:WebExtension:PortMessage"); + mListenersRegistered = true; + } + } + + /** + * This delegate is invoked whenever an extension uses the `tabs` WebExtension API to modify the + * state of a tab. See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + */ + public interface SessionTabDelegate { + /** + * Called when tabs.remove is invoked, this method decides if WebExtension can close the tab. In + * case WebExtension can close the tab, it should close passed GeckoSession and return + * GeckoResult.ALLOW or GeckoResult.DENY in case tab cannot be closed. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove"> + * WebExtensions/API/tabs/remove</a> + * + * @param source An instance of {@link WebExtension} + * @param session An instance of {@link GeckoSession} to be closed. + * @return GeckoResult.ALLOW if the tab will be closed, GeckoResult.DENY otherwise + */ + @UiThread + @NonNull + default GeckoResult<AllowOrDeny> onCloseTab( + @Nullable final WebExtension source, @NonNull final GeckoSession session) { + return GeckoResult.deny(); + } + + /** + * Called when tabs.update is invoked. The uri is provided for informational purposes, there's + * no need to call <code>loadURI</code> on it. The page will be loaded if this method returns + * GeckoResult.ALLOW. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update"> + * WebExtensions/API/tabs/update</a> + * + * @param extension The extension that requested to update the tab. + * @param session The {@link GeckoSession} instance that needs to be updated. + * @param details {@link UpdateTabDetails} instance that describes what needs to be updated for + * this tab. + * @return <code>GeckoResult.ALLOW</code> if the tab will be updated, <code>GeckoResult.DENY + * </code> otherwise. + */ + @UiThread + @NonNull + default GeckoResult<AllowOrDeny> onUpdateTab( + final @NonNull WebExtension extension, + final @NonNull GeckoSession session, + final @NonNull UpdateTabDetails details) { + return GeckoResult.deny(); + } + } + + /** + * Provides details about upating a tab with <code>tabs.update</code>. + * + * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/update"> + * WebExtensions/API/tabs/update </a>. + */ + public static class UpdateTabDetails { + /** + * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs + * should stop being highlighted. If <code>false</code>, does nothing. + */ + @Nullable public final Boolean active; + /** Whether the tab should be discarded automatically by the app when resources are low. */ + @Nullable public final Boolean autoDiscardable; + /** If <code>true</code> and the tab is not highlighted, it should become active by default. */ + @Nullable public final Boolean highlighted; + /** Whether the tab should be muted. */ + @Nullable public final Boolean muted; + /** Whether the tab should be pinned. */ + @Nullable public final Boolean pinned; + /** + * The url that the tab will be navigated to. This url is provided just for informational + * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession} + * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link + * SessionTabDelegate#onUpdateTab} + */ + @Nullable public final String url; + + /** For testing. */ + protected UpdateTabDetails() { + active = null; + autoDiscardable = null; + highlighted = null; + muted = null; + pinned = null; + url = null; + } + + /* package */ UpdateTabDetails(final GeckoBundle bundle) { + active = bundle.getBooleanObject("active"); + autoDiscardable = bundle.getBooleanObject("autoDiscardable"); + highlighted = bundle.getBooleanObject("highlighted"); + muted = bundle.getBooleanObject("muted"); + pinned = bundle.getBooleanObject("pinned"); + url = bundle.getString("url"); + } + } + + /** + * Provides details about creating a tab with <code>tabs.create</code>. See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create"> + * WebExtensions/API/tabs/create </a>. + * + * <p>Whenever a field is not passed in by the extension that value will be <code>null</code>. + */ + public static class CreateTabDetails { + /** + * Whether the tab should become active. If <code>true</code>, non-active highlighted tabs + * should stop being highlighted. If <code>false</code>, does nothing. + */ + @Nullable public final Boolean active; + /** + * The CookieStoreId used for the tab. This option is only available if the extension has the + * "cookies" permission. + */ + @Nullable public final String cookieStoreId; + /** + * Whether the tab is created and made visible in the tab bar without any content loaded into + * memory, a state known as discarded. The tab’s content should be loaded when the tab is + * activated. + */ + @Nullable public final Boolean discarded; + /** The position the tab should take in the window. */ + @Nullable public final Integer index; + /** If true, open this tab in Reader Mode. */ + @Nullable public final Boolean openInReaderMode; + /** Whether the tab should be pinned. */ + @Nullable public final Boolean pinned; + /** + * The url that the tab will be navigated to. This url is provided just for informational + * purposes, there is no need to load the URL manually. The corresponding {@link GeckoSession} + * will be navigated to the right URL after returning <code>GeckoResult.ALLOW</code> from {@link + * TabDelegate#onNewTab} + */ + @Nullable public final String url; + + /** For testing. */ + protected CreateTabDetails() { + active = null; + cookieStoreId = null; + discarded = null; + index = null; + openInReaderMode = null; + pinned = null; + url = null; + } + + /* package */ CreateTabDetails(final GeckoBundle bundle) { + active = bundle.getBooleanObject("active"); + cookieStoreId = bundle.getString("cookieStoreId"); + discarded = bundle.getBooleanObject("discarded"); + index = bundle.getInteger("index"); + openInReaderMode = bundle.getBooleanObject("openInReaderMode"); + pinned = bundle.getBooleanObject("pinned"); + url = bundle.getString("url"); + } + } + + /** + * This delegate is invoked whenever an extension uses the `tabs` WebExtension API and the request + * is not specific to an existing tab, e.g. when creating a new tab. See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + */ + public interface TabDelegate { + /** + * Called when tabs.create is invoked, this method returns a *newly-created* session that + * GeckoView will use to load the requested page on. If the returned value is null the page will + * not be opened. + * + * @param source An instance of {@link WebExtension} + * @param createDetails Information about this tab. + * @return A {@link GeckoResult} which holds the returned GeckoSession. May be null, in which + * case the request for a new tab by the extension will fail. The implementation of onNewTab + * is responsible for maintaining a reference to the returned object, to prevent it from + * being garbage collected. + */ + @UiThread + @Nullable + default GeckoResult<GeckoSession> onNewTab( + @NonNull final WebExtension source, @NonNull final CreateTabDetails createDetails) { + return null; + } + + /** + * Called when runtime.openOptionsPage is invoked with options_ui.open_in_tab = false. In this + * case, GeckoView delegates options page handling to the app. With options_ui.open_in_tab = + * true, {@link #onNewTab} is called instead. + * + * @param source An instance of {@link WebExtension}. + */ + @UiThread + default void onOpenOptionsPage(@NonNull final WebExtension source) {} + } + + /** + * Get the tab delegate for this extension. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + * + * @return The {@link TabDelegate} instance for this extension. + */ + @UiThread + @Nullable + public WebExtension.TabDelegate getTabDelegate() { + return mDelegateController.getTabDelegate(); + } + + /** + * Set the tab delegate for this extension. This delegate will be invoked whenever this extension + * tries to modify the tabs state using the `tabs` WebExtension API. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs">WebExtensions/API/tabs</a>. + * + * @param delegate the {@link TabDelegate} instance for this extension. + */ + @UiThread + public void setTabDelegate(final @Nullable TabDelegate delegate) { + mDelegateController.onTabDelegate(delegate); + } + + @UiThread + @Nullable + public BrowsingDataDelegate getBrowsingDataDelegate() { + return mDelegateController.getBrowsingDataDelegate(); + } + + @UiThread + public void setBrowsingDataDelegate(final @Nullable BrowsingDataDelegate delegate) { + mDelegateController.onBrowsingDataDelegate(delegate); + } + + private static class Sender { + public String webExtensionId; + public String nativeApp; + + public Sender(final String webExtensionId, final String nativeApp) { + this.webExtensionId = webExtensionId; + this.nativeApp = nativeApp; + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof Sender)) { + return false; + } + + final Sender o = (Sender) other; + return webExtensionId.equals(o.webExtensionId) && nativeApp.equals(o.nativeApp); + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {webExtensionId, nativeApp}); + } + } + + // Public wrapper for Listener + public static class SessionController { + private final Listener<SessionTabDelegate> mListener; + + /* package */ void setRuntime(final GeckoRuntime runtime) { + mListener.runtime = runtime; + } + + /* package */ SessionController(final GeckoSession session) { + mListener = new Listener<>(session); + } + + /** + * Defines a message delegate for a Native App. + * + * <p>If a delegate is already present, this delegate will replace the existing one. + * + * <p>This message delegate will be responsible for handling messaging between a WebExtension + * content script running on the {@link GeckoSession}. + * + * <p>Note: To receive messages from content scripts, the WebExtension needs to explicitely + * allow it in {@link WebExtension#WebExtension} by setting {@link + * Flags#ALLOW_CONTENT_MESSAGING}. + * + * @param webExtension {@link WebExtension} that this delegate receives messages from. + * @param delegate {@link MessageDelegate} that will receive messages from this session. + * @param nativeApp which native app id this message delegate will handle messaging for. + * @see WebExtension#setMessageDelegate + */ + @AnyThread + public void setMessageDelegate( + final @NonNull WebExtension webExtension, + final @Nullable WebExtension.MessageDelegate delegate, + final @NonNull String nativeApp) { + mListener.setMessageDelegate(webExtension, delegate, nativeApp); + } + + /** + * Get the message delegate for <code>nativeApp</code>. + * + * @param extension {@link WebExtension} that this delegate receives messages from. + * @param nativeApp identifier for the native app + * @return The {@link MessageDelegate} attached to the <code>nativeApp</code>. <code>null</code> + * if no delegate is present. + */ + @AnyThread + public @Nullable WebExtension.MessageDelegate getMessageDelegate( + final @NonNull WebExtension extension, final @NonNull String nativeApp) { + return mListener.getMessageDelegate(extension, nativeApp); + } + + /** + * Set the Action delegate for this session. + * + * <p>This delegate will receive page and browser action overrides specific to this session. The + * default Action will be received by the delegate set by {@link + * WebExtension#setActionDelegate}. + * + * @param extension the {@link WebExtension} object this delegate will receive updates for + * @param delegate the {@link ActionDelegate} that will receive updates. + * @see WebExtension.Action + */ + @AnyThread + public void setActionDelegate( + final @NonNull WebExtension extension, final @Nullable ActionDelegate delegate) { + mListener.setActionDelegate(extension, delegate); + } + + /** + * Get the Action delegate for this session. + * + * @param extension {@link WebExtension} that this delegates receive updates for. + * @return {@link ActionDelegate} for this session + */ + @AnyThread + @Nullable + public ActionDelegate getActionDelegate(final @NonNull WebExtension extension) { + return mListener.getActionDelegate(extension); + } + + /** + * Set the TabDelegate for this session. + * + * <p>This delegate will receive messages specific for this session coming from the WebExtension + * <code>tabs</code> API. + * + * @param extension the {@link WebExtension} this delegate will receive updates for + * @param delegate the {@link TabDelegate} that will receive updates. + * @see WebExtension#setTabDelegate + */ + @AnyThread + public void setTabDelegate( + final @NonNull WebExtension extension, final @Nullable SessionTabDelegate delegate) { + mListener.setTabDelegate(extension, delegate); + } + + /** + * Get the TabDelegate for the given extension. + * + * @param extension the {@link WebExtension} this delegate refers to. + * @return the current {@link SessionTabDelegate} instance + */ + @AnyThread + @Nullable + public SessionTabDelegate getTabDelegate(final @NonNull WebExtension extension) { + return mListener.getTabDelegate(extension); + } + } + + /* package */ static final class Listener<TabDelegate> implements BundleEventListener { + private final HashMap<Sender, MessageDelegate> mMessageDelegates; + private final HashMap<String, ActionDelegate> mActionDelegates; + private final HashMap<String, BrowsingDataDelegate> mBrowsingDataDelegates; + private final HashMap<String, TabDelegate> mTabDelegates; + private final HashMap<String, DownloadDelegate> mDownloadDelegates; + + private final GeckoSession mSession; + private final EventDispatcher mEventDispatcher; + + private boolean mActionDelegateRegistered = false; + private boolean mBrowsingDataDelegateRegistered = false; + private boolean mTabDelegateRegistered = false; + + public GeckoRuntime runtime; + + public Listener(final GeckoRuntime runtime) { + this(null, runtime); + } + + public Listener(final GeckoSession session) { + this(session, null); + + // Close tab event is forwarded to the main listener so we need to listen + // to it here. + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:NewTab", + "GeckoView:WebExtension:UpdateTab", + "GeckoView:WebExtension:CloseTab", + "GeckoView:WebExtension:OpenOptionsPage"); + mTabDelegateRegistered = true; + } + + private Listener(final GeckoSession session, final GeckoRuntime runtime) { + mMessageDelegates = new HashMap<>(); + mActionDelegates = new HashMap<>(); + mBrowsingDataDelegates = new HashMap<>(); + mTabDelegates = new HashMap<>(); + mDownloadDelegates = new HashMap<>(); + mEventDispatcher = + session != null ? session.getEventDispatcher() : EventDispatcher.getInstance(); + mSession = session; + this.runtime = runtime; + + // We queue these messages if the delegate has not been attached yet, + // so we need to start listening immediately. + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:Message", + "GeckoView:WebExtension:PortMessage", + "GeckoView:WebExtension:Connect", + "GeckoView:WebExtension:Disconnect", + "GeckoView:BrowsingData:GetSettings", + "GeckoView:BrowsingData:Clear", + "GeckoView:WebExtension:Download"); + } + + public void unregisterWebExtension(final WebExtension extension) { + mMessageDelegates.remove(extension.id); + mActionDelegates.remove(extension.id); + mBrowsingDataDelegates.remove(extension.id); + mTabDelegates.remove(extension.id); + mDownloadDelegates.remove(extension.id); + } + + public void setTabDelegate(final WebExtension webExtension, final TabDelegate delegate) { + if (!mTabDelegateRegistered && delegate != null) { + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:WebExtension:NewTab", + "GeckoView:WebExtension:UpdateTab", + "GeckoView:WebExtension:CloseTab", + "GeckoView:WebExtension:OpenOptionsPage"); + mTabDelegateRegistered = true; + } + + mTabDelegates.put(webExtension.id, delegate); + } + + public TabDelegate getTabDelegate(final WebExtension webExtension) { + return mTabDelegates.get(webExtension.id); + } + + public void setBrowsingDataDelegate( + final WebExtension webExtension, final BrowsingDataDelegate delegate) { + mBrowsingDataDelegates.put(webExtension.id, delegate); + } + + public BrowsingDataDelegate getBrowsingDataDelegate(final WebExtension webExtension) { + return mBrowsingDataDelegates.get(webExtension.id); + } + + public void setActionDelegate( + final WebExtension webExtension, final WebExtension.ActionDelegate delegate) { + if (!mActionDelegateRegistered && delegate != null) { + mEventDispatcher.registerUiThreadListener( + this, + "GeckoView:BrowserAction:Update", + "GeckoView:BrowserAction:OpenPopup", + "GeckoView:PageAction:Update", + "GeckoView:PageAction:OpenPopup"); + mActionDelegateRegistered = true; + } + + mActionDelegates.put(webExtension.id, delegate); + } + + public WebExtension.ActionDelegate getActionDelegate(final WebExtension webExtension) { + return mActionDelegates.get(webExtension.id); + } + + public void setMessageDelegate( + final WebExtension webExtension, + final WebExtension.MessageDelegate delegate, + final String nativeApp) { + mMessageDelegates.put(new Sender(webExtension.id, nativeApp), delegate); + + if (runtime != null && delegate != null) { + runtime + .getWebExtensionController() + .releasePendingMessages(webExtension, nativeApp, mSession); + } + } + + public WebExtension.MessageDelegate getMessageDelegate( + final WebExtension webExtension, final String nativeApp) { + return mMessageDelegates.get(new Sender(webExtension.id, nativeApp)); + } + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (runtime == null) { + return; + } + + runtime.getWebExtensionController().handleMessage(event, message, callback, mSession); + } + + public void setDownloadDelegate( + final @NonNull WebExtension extension, final @Nullable DownloadDelegate delegate) { + mDownloadDelegates.put(extension.id, delegate); + } + + public WebExtension.DownloadDelegate getDownloadDelegate(final WebExtension extension) { + return mDownloadDelegates.get(extension.id); + } + } + + /** + * Describes the sender of a message from a WebExtension. + * + * <p>See also: <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/MessageSender"> + * WebExtensions/API/runtime/MessageSender</a> + */ + @UiThread + public static class MessageSender { + /** {@link WebExtension} that sent this message. */ + public final @NonNull WebExtension webExtension; + + /** + * {@link GeckoSession} that sent this message. <code>null</code> if coming from a background + * script. + */ + public final @Nullable GeckoSession session; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ENV_TYPE_UNKNOWN, ENV_TYPE_EXTENSION, ENV_TYPE_CONTENT_SCRIPT}) + public @interface EnvType {} + /* package */ static final int ENV_TYPE_UNKNOWN = 0; + /** This sender originated inside a privileged extension context like a background script. */ + public static final int ENV_TYPE_EXTENSION = 1; + + /** This sender originated inside a content script. */ + public static final int ENV_TYPE_CONTENT_SCRIPT = 2; + + /** + * Type of environment that sent this message, either + * + * <ul> + * <li>{@link MessageSender#ENV_TYPE_EXTENSION} if the message was sent from a background page + * <li>{@link MessageSender#ENV_TYPE_CONTENT_SCRIPT} if the message was sent from a content + * script + * </ul> + */ + // TODO: Bug 1534640 do we need ENV_TYPE_EXTENSION_PAGE ? + public final @EnvType int environmentType; + + /** + * URL of the frame that sent this message. + * + * <p>Use this value together with {@link MessageSender#isTopLevel} to verify that the message + * is coming from the expected page. Only top level frames can be trusted. + */ + public final @NonNull String url; + + /* package */ final boolean isTopLevel; + + /* package */ MessageSender( + final @NonNull WebExtension webExtension, + final @Nullable GeckoSession session, + final @Nullable String url, + final @EnvType int environmentType, + final boolean isTopLevel) { + this.webExtension = webExtension; + this.session = session; + this.isTopLevel = isTopLevel; + this.url = url; + this.environmentType = environmentType; + } + + /** Override for testing. */ + protected MessageSender() { + this.webExtension = null; + this.session = null; + this.isTopLevel = false; + this.url = null; + this.environmentType = ENV_TYPE_UNKNOWN; + } + + /** + * Whether this MessageSender belongs to a top level frame. + * + * @return true if the MessageSender was sent from the top level frame, false otherwise. + */ + public boolean isTopLevel() { + return this.isTopLevel; + } + } + + /* package */ static WebExtension fromBundle( + final DelegateControllerProvider provider, final GeckoBundle bundle) { + if (bundle == null) { + return null; + } + return new WebExtension(provider, bundle.getBundle("extension")); + } + + /** + * Represents either a Browser Action or a Page Action from the WebExtension API. + * + * <p>Instances of this class may represent the default <code>Action</code> which applies to all + * WebExtension tabs or a tab-specific override. To reconstruct the full <code>Action</code> + * object, you can use {@link Action#withDefault}. + * + * <p>Tab specific overrides can be obtained by registering a delegate using {@link + * SessionController#setActionDelegate}, while default values can be obtained by registering a + * delegate using {@link #setActionDelegate}. <br> + * See also + * + * <ul> + * <li><a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction"> + * WebExtensions/API/browserAction </a> + * <li><a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction"> + * WebExtensions/API/pageAction </a> + * </ul> + */ + @AnyThread + public static class Action { + /** + * Title of this Action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/getTitle"> + * pageAction/getTitle</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getTitle"> + * browserAction/getTitle</a> + */ + public final @Nullable String title; + /** + * Icon for this Action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/setIcon"> + * pageAction/setIcon</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon"> + * browserAction/setIcon</a> + */ + public final @Nullable Image icon; + /** + * Whether this action is enabled and should be visible. + * + * <p>Note: for page action, this is <code>true</code> when the extension calls <code> + * pageAction.show</code> and <code>false</code> when the extension calls <code>pageAction.hide + * </code>. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction/show"> + * pageAction/show</a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/enabled"> + * browserAction/enabled</a> + */ + public final @Nullable Boolean enabled; + /** + * Badge text for this action. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeText"> + * browserAction/getBadgeText</a> + */ + public final @Nullable String badgeText; + /** + * Background color for the badge for this Action. + * + * <p>This method will return an Android color int that can be used in {@link + * android.widget.TextView#setBackgroundColor(int)} and similar methods. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeBackgroundColor"> + * browserAction/getBadgeBackgroundColor</a> + */ + public final @Nullable Integer badgeBackgroundColor; + /** + * Text color for the badge for this Action. + * + * <p>This method will return an Android color int that can be used in {@link + * android.widget.TextView#setTextColor(int)} and similar methods. + * + * <p>See also: <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/getBadgeTextColor"> + * browserAction/getBadgeTextColor</a> + */ + public final @Nullable Integer badgeTextColor; + + private final WebExtension mExtension; + + /* package */ static final int TYPE_BROWSER_ACTION = 1; + /* package */ static final int TYPE_PAGE_ACTION = 2; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({TYPE_BROWSER_ACTION, TYPE_PAGE_ACTION}) + public @interface ActionType {} + + /* package */ final @ActionType int type; + + /* package */ Action( + final @ActionType int type, final GeckoBundle bundle, final WebExtension extension) { + mExtension = extension; + + this.type = type; + + title = bundle.getString("title"); + badgeText = bundle.getString("badgeText"); + badgeBackgroundColor = colorFromRgbaArray(bundle.getDoubleArray("badgeBackgroundColor")); + badgeTextColor = colorFromRgbaArray(bundle.getDoubleArray("badgeTextColor")); + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + + if (bundle.getBoolean("patternMatching", false)) { + // This action was enabled by pattern matching + enabled = true; + } else if (bundle.containsKey("enabled")) { + enabled = bundle.getBoolean("enabled"); + } else { + enabled = null; + } + } + + private Integer colorFromRgbaArray(final double[] c) { + if (c == null) { + return null; + } + + return Color.argb((int) c[3], (int) c[0], (int) c[1], (int) c[2]); + } + + @Override + public String toString() { + return "Action {\n" + + "\ttitle: " + + this.title + + ",\n" + + "\ticon: " + + this.icon + + ",\n" + + "\tenabled: " + + this.enabled + + ",\n" + + "\tbadgeText: " + + this.badgeText + + ",\n" + + "\tbadgeTextColor: " + + this.badgeTextColor + + ",\n" + + "\tbadgeBackgroundColor: " + + this.badgeBackgroundColor + + ",\n" + + "}"; + } + + // For testing + protected Action() { + type = TYPE_BROWSER_ACTION; + mExtension = null; + title = null; + icon = null; + enabled = null; + badgeText = null; + badgeTextColor = null; + badgeBackgroundColor = null; + } + + /** + * Merges values from this Action with the default Action. + * + * @param defaultValue the default Action as received from {@link + * ActionDelegate#onBrowserAction} or {@link ActionDelegate#onPageAction}. + * @return an {@link Action} where all <code>null</code> values from this instance are replaced + * with values from <code>defaultValue</code>. + * @throws IllegalArgumentException if defaultValue is not of the same type, e.g. if this Action + * is a Page Action and default value is a Browser Action. + */ + @NonNull + public Action withDefault(final @NonNull Action defaultValue) { + return new Action(this, defaultValue); + } + + /** + * @see Action#withDefault + */ + private Action(final Action source, final Action defaultValue) { + if (source.type != defaultValue.type) { + throw new IllegalArgumentException("defaultValue must be of the same type."); + } + + type = source.type; + mExtension = source.mExtension; + + title = source.title != null ? source.title : defaultValue.title; + icon = source.icon != null ? source.icon : defaultValue.icon; + enabled = source.enabled != null ? source.enabled : defaultValue.enabled; + badgeText = source.badgeText != null ? source.badgeText : defaultValue.badgeText; + badgeTextColor = + source.badgeTextColor != null ? source.badgeTextColor : defaultValue.badgeTextColor; + badgeBackgroundColor = + source.badgeBackgroundColor != null + ? source.badgeBackgroundColor + : defaultValue.badgeBackgroundColor; + } + + /** Notifies the extension that the user has clicked on this Action. */ + @UiThread + public void click() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", mExtension.id); + + // The click event will return the popup uri if we should open a popup in + // response to clicking on the action button. + final GeckoResult<String> popupUri; + if (type == TYPE_BROWSER_ACTION) { + popupUri = + EventDispatcher.getInstance().queryString("GeckoView:BrowserAction:Click", bundle); + } else if (type == TYPE_PAGE_ACTION) { + popupUri = EventDispatcher.getInstance().queryString("GeckoView:PageAction:Click", bundle); + } else { + throw new IllegalStateException("Unknown Action type"); + } + + popupUri.accept( + uri -> { + if (uri == null || uri.isEmpty()) { + return; + } + + final ActionDelegate delegate = mExtension.mDelegateController.getActionDelegate(); + if (delegate == null) { + return; + } + + // The .accept method will be called from the UIThread in this case because + // the GeckoResult instance was created on the UIThread + @SuppressLint("WrongThread") + final GeckoResult<GeckoSession> popup = delegate.onTogglePopup(mExtension, this); + openPopup(popup, uri); + }); + } + + /* package */ void openPopup(final GeckoResult<GeckoSession> popup, final String popupUri) { + if (popup == null) { + return; + } + + popup.accept( + session -> { + if (session == null) { + return; + } + + session.getSettings().setIsPopup(true); + session.loadUri(popupUri); + }); + } + } + + /** + * Receives updates whenever a Browser action or a Page action has been defined by an extension. + * + * <p>This delegate will receive the default action when registered with {@link + * WebExtension#setActionDelegate}. To receive {@link GeckoSession}-specific overrides you can use + * {@link SessionController#setActionDelegate}. + */ + public interface ActionDelegate { + /** + * Called whenever a browser action is defined or updated. + * + * <p>This method will be called whenever an extension that defines a browser action is + * registered or the properties of the Action are updated. + * + * <p>See also <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction"> + * WebExtensions/API/browserAction </a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/browser_action"> + * WebExtensions/manifest.json/browser_action </a>. + * + * @param extension The extension that defined this browser action. + * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action + * override applies. <code>null</code> if <code>action</code> is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if <code>session</code> is <code>null</code>. + */ + @UiThread + default void onBrowserAction( + final @NonNull WebExtension extension, + final @Nullable GeckoSession session, + final @NonNull Action action) {} + /** + * Called whenever a page action is defined or updated. + * + * <p>This method will be called whenever an extension that defines a page action is registered + * or the properties of the Action are updated. + * + * <p>See also <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/pageAction"> + * WebExtensions/API/pageAction </a>, <a target=_blank + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/page_action"> + * WebExtensions/manifest.json/page_action </a>. + * + * @param extension The extension that defined this page action. + * @param session Either the {@link GeckoSession} corresponding to the tab to which this Action + * override applies. <code>null</code> if <code>action</code> is the new default value. + * @param action {@link Action} containing the override values for this {@link GeckoSession} or + * the default value if <code>session</code> is <code>null</code>. + */ + @UiThread + default void onPageAction( + final @NonNull WebExtension extension, + final @Nullable GeckoSession session, + final @NonNull Action action) {} + + /** + * Called whenever the action wants to toggle a popup view. + * + * @param extension The extension that wants to display a popup + * @param action The action where the popup is defined + * @return A GeckoSession that will be used to display the pop-up, null if no popup will be + * displayed. + */ + @UiThread + @Nullable + default GeckoResult<GeckoSession> onTogglePopup( + final @NonNull WebExtension extension, final @NonNull Action action) { + return null; + } + + /** + * Called whenever the action wants to open a popup view. + * + * @param extension The extension that wants to display a popup + * @param action The action where the popup is defined + * @return A GeckoSession that will be used to display the pop-up, null if no popup will be + * displayed. + */ + @UiThread + @Nullable + default GeckoResult<GeckoSession> onOpenPopup( + final @NonNull WebExtension extension, final @NonNull Action action) { + return null; + } + } + + /** Extension thrown when an error occurs during extension installation. */ + public static class InstallException extends Exception { + public static class ErrorCodes { + /** The download failed due to network problems. */ + public static final int ERROR_NETWORK_FAILURE = -1; + /** The downloaded file did not match the provided hash. */ + public static final int ERROR_INCORRECT_HASH = -2; + /** The downloaded file seems to be corrupted in some way. */ + public static final int ERROR_CORRUPT_FILE = -3; + /** An error occurred trying to write to the filesystem. */ + public static final int ERROR_FILE_ACCESS = -4; + /** The extension must be signed and isn't. */ + public static final int ERROR_SIGNEDSTATE_REQUIRED = -5; + /** The downloaded extension had a different type than expected. */ + public static final int ERROR_UNEXPECTED_ADDON_TYPE = -6; + /** The downloaded extension had a different version than expected */ + public static final int ERROR_UNEXPECTED_ADDON_VERSION = -9; + /** The extension did not have the expected ID. */ + public static final int ERROR_INCORRECT_ID = -7; + /** The extension did not have the expected ID. */ + public static final int ERROR_INVALID_DOMAIN = -8; + /** The extension install was canceled. */ + public static final int ERROR_USER_CANCELED = -100; + /** The extension install was postponed until restart. */ + public static final int ERROR_POSTPONED = -101; + + /** For testing. */ + protected ErrorCodes() {} + } + + /** These states should match gecko's AddonManager.STATE_* constants. */ + private static class StateCodes { + public static final int STATE_POSTPONED = 7; + public static final int STATE_CANCELED = 12; + } + + /* package */ static Throwable fromQueryException(final Throwable exception) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) exception; + final Object response = queryException.data; + if (response instanceof GeckoBundle && ((GeckoBundle) response).containsKey("installError")) { + final GeckoBundle bundle = (GeckoBundle) response; + int errorCode = bundle.getInt("installError"); + final int installState = bundle.getInt("state"); + if (errorCode == 0 && installState == StateCodes.STATE_CANCELED) { + errorCode = ErrorCodes.ERROR_USER_CANCELED; + } else if (errorCode == 0 && installState == StateCodes.STATE_POSTPONED) { + errorCode = ErrorCodes.ERROR_POSTPONED; + } + return new WebExtension.InstallException(errorCode); + } else { + return new Exception(response.toString()); + } + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + value = { + ErrorCodes.ERROR_NETWORK_FAILURE, + ErrorCodes.ERROR_INCORRECT_HASH, + ErrorCodes.ERROR_CORRUPT_FILE, + ErrorCodes.ERROR_FILE_ACCESS, + ErrorCodes.ERROR_SIGNEDSTATE_REQUIRED, + ErrorCodes.ERROR_UNEXPECTED_ADDON_TYPE, + ErrorCodes.ERROR_UNEXPECTED_ADDON_VERSION, + ErrorCodes.ERROR_INCORRECT_ID, + ErrorCodes.ERROR_INVALID_DOMAIN, + ErrorCodes.ERROR_USER_CANCELED, + ErrorCodes.ERROR_POSTPONED, + }) + public @interface Codes {} + + /** One of {@link ErrorCodes} that provides more information about this exception. */ + public final @Codes int code; + + /** For testing */ + protected InstallException() { + this.code = ErrorCodes.ERROR_NETWORK_FAILURE; + } + + @Override + public String toString() { + return "InstallException: " + code; + } + + /* package */ InstallException(final @Codes int code) { + this.code = code; + } + } + + /** + * Set the Action delegate for this WebExtension. + * + * <p>This delegate will receive updates every time the default Action value changes. + * + * <p>To listen for {@link GeckoSession}-specific updates, use {@link + * SessionController#setActionDelegate} + * + * @param delegate {@link ActionDelegate} that will receive updates. + */ + @AnyThread + public void setActionDelegate(final @Nullable ActionDelegate delegate) { + mDelegateController.onActionDelegate(delegate); + + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", id); + + if (delegate != null) { + EventDispatcher.getInstance().dispatch("GeckoView:ActionDelegate:Attached", bundle); + } + } + + /** + * Describes the signed status for a {@link WebExtension}. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on signing + * in Firefox. </a> + */ + public static class SignedStateFlags { + // Keep in sync with AddonManager.jsm + /** + * This extension may be signed but by a certificate that doesn't chain to our our trusted + * certificate. + */ + public static final int UNKNOWN = -1; + /** This extension is unsigned. */ + public static final int MISSING = 0; + /** This extension has been preliminarily reviewed. */ + public static final int PRELIMINARY = 1; + /** This extension has been fully reviewed. */ + public static final int SIGNED = 2; + /** This extension is a system add-on. */ + public static final int SYSTEM = 3; + /** This extension is signed with a "Mozilla Extensions" certificate. */ + public static final int PRIVILEGED = 4; + + /* package */ static final int LAST = PRIVILEGED; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + SignedStateFlags.UNKNOWN, + SignedStateFlags.MISSING, + SignedStateFlags.PRELIMINARY, + SignedStateFlags.SIGNED, + SignedStateFlags.SYSTEM, + SignedStateFlags.PRIVILEGED + }) + public @interface SignedState {} + + /** + * Describes the blocklist state for a {@link WebExtension}. See <a + * href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist">Add-ons that + * cause stability or security issues are put on a blocklist </a>. + */ + public static class BlocklistStateFlags { + // Keep in sync with nsIBlocklistService.idl + /** This extension does not appear in the blocklist. */ + public static final int NOT_BLOCKED = 0; + /** + * This extension is in the blocklist but the problem is not severe enough to warant forcibly + * blocking. + */ + public static final int SOFTBLOCKED = 1; + /** This extension should be blocked and never used. */ + public static final int BLOCKED = 2; + /** This extension is considered outdated, and there is a known update available. */ + public static final int OUTDATED = 3; + /** This extension is vulnerable and there is an update. */ + public static final int VULNERABLE_UPDATE_AVAILABLE = 4; + /** This extension is vulnerable and there is no update. */ + public static final int VULNERABLE_NO_UPDATE = 5; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + BlocklistStateFlags.NOT_BLOCKED, + BlocklistStateFlags.SOFTBLOCKED, + BlocklistStateFlags.BLOCKED, + BlocklistStateFlags.OUTDATED, + BlocklistStateFlags.VULNERABLE_UPDATE_AVAILABLE, + BlocklistStateFlags.VULNERABLE_NO_UPDATE + }) + public @interface BlocklistState {} + + public static class DisabledFlags { + /** The extension has been disabled by the user */ + public static final int USER = 1 << 1; + + /** + * The extension has been disabled by the blocklist. The details of why this extension was + * blocked can be found in {@link MetaData#blocklistState}. + */ + public static final int BLOCKLIST = 1 << 2; + + /** + * The extension has been disabled by the application. To enable the extension you can use + * {@link WebExtensionController#enable} passing in {@link + * WebExtensionController.EnableSource#APP} as <code>source</code>. + */ + public static final int APP = 1 << 3; + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {DisabledFlags.USER, DisabledFlags.BLOCKLIST, DisabledFlags.APP}) + public @interface EnabledFlags {} + + /** Provides information about a {@link WebExtension}. */ + public class MetaData { + /** + * Main {@link Image} branding for this {@link WebExtension}. Can be used when displaying + * prompts. + */ + public final @NonNull Image icon; + /** + * API permissions requested or granted to this extension. + * + * <p>Permission identifiers match entries in the manifest, see <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#API_permissions"> + * API permissions </a>. + */ + public final @NonNull String[] permissions; + /** + * Host permissions requested or granted to this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions#Host_permissions"> + * Host permissions </a>. + */ + public final @NonNull String[] origins; + /** + * Branding name for this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/name"> + * manifest.json/name </a> + */ + public final @Nullable String name; + /** + * Branding description for this extension. This string will be localized using the current + * GeckoView language setting. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/description"> + * manifest.json/description </a> + */ + public final @Nullable String description; + /** + * Version string for this extension. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/version"> + * manifest.json/version </a> + */ + public final @NonNull String version; + /** + * Creator name as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer"> + * manifest.json/developer </a> + */ + public final @Nullable String creatorName; + /** + * Creator url as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/developer"> + * manifest.json/developer </a> + */ + public final @Nullable String creatorUrl; + /** + * Homepage url as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/homepage_url"> + * manifest.json/homepage_url </a> + */ + public final @Nullable String homepageUrl; + /** + * Options page as provided in the manifest. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui"> + * manifest.json/options_ui </a> + */ + public final @Nullable String optionsPageUrl; + /** + * Whether the options page should be open in a Tab or not. + * + * <p>See <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/options_ui#Syntax"> + * manifest.json/options_ui#Syntax </a> + */ + public final boolean openOptionsPageInTab; + /** + * Whether or not this is a recommended extension. + * + * <p>See <a href="https://blog.mozilla.org/firefox/firefox-recommended-extensions/">Recommended + * Extensions program </a> + */ + public final boolean isRecommended; + /** + * Blocklist status for this extension. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-ons-cause-issues-are-on-blocklist"> + * Add-ons that cause stability or security issues are put on a blocklist </a>. + */ + public final @BlocklistState int blocklistState; + /** + * Signed status for this extension. + * + * <p>See <a href="https://support.mozilla.org/en-US/kb/add-on-signing-in-firefox">Add-on + * signing in Firefox. </a>. + */ + public final @SignedState int signedState; + + /** + * Disabled binary flags for this extension. + * + * <p>This will be either equal to <code>0</code> if the extension is enabled or will contain + * one or more flags from {@link DisabledFlags}. + * + * <p>e.g. if the extension has been disabled by the user, the value in {@link + * DisabledFlags#USER} will be equal to <code>1</code>: + * + * <pre><code> + * boolean isUserDisabled = metaData.disabledFlags + * & DisabledFlags.USER > 0; + * </code></pre> + */ + public final @EnabledFlags int disabledFlags; + + /** + * Root URL for this extension's pages. Can be used to determine if a given URL belongs to this + * extension. + */ + public final @NonNull String baseUrl; + + /** + * Whether this extension is allowed to run in private browsing or not. To modify this value use + * {@link WebExtensionController#setAllowedInPrivateBrowsing}. + */ + public final boolean allowedInPrivateBrowsing; + + /** Whether this extension is enabled or not. */ + public final boolean enabled; + + /** + * Whether this extension is temporary or not. Temporary extensions are not retained and will be + * uninstalled when the browser exits. + */ + public final boolean temporary; + + /** Override for testing. */ + protected MetaData() { + icon = null; + permissions = null; + origins = null; + name = null; + description = null; + version = null; + creatorName = null; + creatorUrl = null; + homepageUrl = null; + optionsPageUrl = null; + openOptionsPageInTab = false; + isRecommended = false; + blocklistState = BlocklistStateFlags.NOT_BLOCKED; + signedState = SignedStateFlags.UNKNOWN; + disabledFlags = 0; + enabled = true; + temporary = false; + baseUrl = null; + allowedInPrivateBrowsing = false; + } + + /* package */ MetaData(final GeckoBundle bundle) { + // We only expose permissions that the embedder should prompt for + permissions = bundle.getStringArray("promptPermissions"); + origins = bundle.getStringArray("origins"); + description = bundle.getString("description"); + version = bundle.getString("version"); + creatorName = bundle.getString("creatorName"); + creatorUrl = bundle.getString("creatorURL"); + homepageUrl = bundle.getString("homepageURL"); + name = bundle.getString("name"); + optionsPageUrl = bundle.getString("optionsPageURL"); + openOptionsPageInTab = bundle.getBoolean("openOptionsPageInTab"); + isRecommended = bundle.getBoolean("isRecommended"); + blocklistState = bundle.getInt("blocklistState", BlocklistStateFlags.NOT_BLOCKED); + enabled = bundle.getBoolean("enabled", false); + temporary = bundle.getBoolean("temporary", false); + baseUrl = bundle.getString("baseURL"); + allowedInPrivateBrowsing = bundle.getBoolean("privateBrowsingAllowed", false); + + final int signedState = bundle.getInt("signedState", SignedStateFlags.UNKNOWN); + if (signedState <= SignedStateFlags.LAST) { + this.signedState = signedState; + } else { + Log.e(LOGTAG, "Unrecognized signed state: " + signedState); + this.signedState = SignedStateFlags.UNKNOWN; + } + + int disabledFlags = 0; + final String[] disabledFlagsString = bundle.getStringArray("disabledFlags"); + + for (final String flag : disabledFlagsString) { + if (flag.equals("userDisabled")) { + disabledFlags |= DisabledFlags.USER; + } else if (flag.equals("blocklistDisabled")) { + disabledFlags |= DisabledFlags.BLOCKLIST; + } else if (flag.equals("appDisabled")) { + disabledFlags |= DisabledFlags.APP; + } else { + Log.e(LOGTAG, "Unrecognized disabledFlag state: " + flag); + } + } + this.disabledFlags = disabledFlags; + + if (bundle.containsKey("icons")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icons")); + } else { + icon = null; + } + } + } + + // TODO: make public bug 1595822 + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = { + Context.NONE, + Context.BOOKMARK, + Context.BROWSER_ACTION, + Context.PAGE_ACTION, + Context.TAB, + Context.TOOLS_MENU + }) + public @interface ContextFlags {} + + /** + * Flags to determine which contexts a menu item should be shown in. See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ContextType> + * menus.ContextType</a>. + */ + static class Context { + /** Shows the menu item in no contexts. */ + static final int NONE = 0; + + /** + * Shows the menu item when the user context-clicks an item on the bookmarks toolbar, bookmarks + * menu, bookmarks sidebar, or Library window. + */ + static final int BOOKMARK = 1 << 1; + + /** Shows the menu item when the user context-clicks the extension's browser action. */ + static final int BROWSER_ACTION = 1 << 2; + + /** Shows the menu item when the user context-clicks on the extension's page action. */ + static final int PAGE_ACTION = 1 << 3; + + /** Shows when the user context-clicks on a tab (such as the element on the tab bar.) */ + static final int TAB = 1 << 4; + + /** Adds the item to the browser's tools menu. */ + static final int TOOLS_MENU = 1 << 5; + } + + // TODO: make public bug 1595822 + + /** + * Represents an addition to the context menu by an extension. + * + * <p>In the <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus>menus</a> + * API, all elements added by one extension should be collapsed under one header. This class + * represents all of one extension's menu items, as well as the icon that should be used with that + * header. + */ + static class Menu { + /** List of menu items that belong to this extension. */ + final @NonNull List<MenuItem> items; + + /** Icon for this extension. */ + final @Nullable Image icon; + + /** Title for the menu header. */ + final @Nullable String title; + + /** The extension adding this Menu to the context menu. */ + final @NonNull WebExtension extension; + + /* package */ Menu(final @NonNull WebExtension extension, final GeckoBundle bundle) { + this.extension = extension; + title = bundle.getString("title", ""); + final GeckoBundle[] items = bundle.getBundleArray("items"); + this.items = new ArrayList<>(); + if (items != null) { + for (final GeckoBundle item : items) { + this.items.add(new MenuItem(this.extension, item)); + } + } + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + } + + /** Notifies the extension that a user has opened the context menu. */ + void show() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", extension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuShow", bundle); + } + + /** Notifies the extension that a user has hidden the context menu. */ + void hide() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", extension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuHide", bundle); + } + } + + // TODO: make public bug 1595822 + /** + * Represents an item in the menu. + * + * <p>If there is only one menu item in the list, the embedder should display that item as itself, + * not under a header. + */ + static class MenuItem { + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = false, + value = {MenuType.NORMAL, MenuType.CHECKBOX, MenuType.RADIO, MenuType.SEPARATOR}) + public @interface Type {} + + /** A set of constants that represents the display type of this menu item. */ + static class MenuType { + /** + * This represents a menu item that just displays a label. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.normal</a> + */ + static final int NORMAL = 0; + + /** + * This represents a menu item that can be selected and deselected. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.checkbox</a> + */ + static final int CHECKBOX = 1; + + /** + * This represents a menu item that is one of a group of choices. All menu items for an + * extension that are of type radio are part of one radio group. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.radio</a> + */ + static final int RADIO = 2; + + /** + * This represents a line separating elements. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/ItemType> + * menus.ItemType.separator</a> + */ + static final int SEPARATOR = 3; + } + + /** + * Direct children for this menu item. These should be displayed as a sub-menu. + * + * <p>See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create> + * createProperties.parentId</a> + */ + final @Nullable List<MenuItem> children; + + /** One of the {@link Type} constants. Determines the type of the action. */ + final @Type int type; + + /** + * The id of this menu item. See <a + * href=https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/menus/create> + * createProperties.id</a> + */ + final @Nullable String id; + + /** Determines if the menu item should be currently displayed. */ + final boolean visible; + + /** The title to be displayed for this menu item. */ + final @Nullable String title; + + /** Whether or not the menu item is initially checked. Defaults to false. */ + final boolean checked; + + /** Contexts that this menu item should be shown in. */ + final @ContextFlags int contexts; + + /** Icon for this menu item. */ + final @Nullable Image icon; + + final WebExtension mExtension; + + /** + * Creates a new menu item using a bundle and a reference to the extension that this item + * belongs to. + * + * @param extension WebExtension object. + * @param bundle GeckoBundle containing the item information. + */ + /* package */ MenuItem(final WebExtension extension, final GeckoBundle bundle) { + title = bundle.getString("title"); + mExtension = extension; + checked = bundle.getBoolean("checked", false); + visible = bundle.getBoolean("visible", true); + id = bundle.getString("id"); + contexts = bundle.getInt("contexts"); + type = bundle.getInt("type"); + children = new ArrayList<>(); + + if (bundle.containsKey("icon")) { + icon = Image.fromSizeSrcBundle(bundle.getBundle("icon")); + } else { + icon = null; + } + } + + /** Notifies the extension that the user has clicked on this menu item. */ + void click() { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("menuId", this.id); + bundle.putString("extensionId", mExtension.id); + + EventDispatcher.getInstance().dispatch("GeckoView:WebExtension:MenuClick", bundle); + } + } + + public interface DownloadDelegate { + /** + * Method that is called when Web Extension requests a download (when downloads.download() is + * called in Web Extension) + * + * @param source - Web Extension that requested the download + * @param request - contains the {@link WebRequest} and additional parameters for the request + * @return {@link DownloadInitData} instance + */ + @AnyThread + @Nullable + default GeckoResult<WebExtension.DownloadInitData> onDownload( + @NonNull final WebExtension source, @NonNull final DownloadRequest request) { + return null; + } + } + + /** + * Set the download delegate for this extension. This delegate will be invoked whenever this + * extension tries to use the `downloads` WebExtension API. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions/API/downloads</a>. + * + * @param delegate the {@link DownloadDelegate} instance for this extension. + */ + @UiThread + public void setDownloadDelegate(final @Nullable DownloadDelegate delegate) { + mDelegateController.onDownloadDelegate(delegate); + } + + /** + * Get the download delegate for this extension. + * + * <p>See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">WebExtensions + * downloads API</a>. + * + * @return The {@link DownloadDelegate} instance for this extension. + */ + @UiThread + @Nullable + public DownloadDelegate getDownloadDelegate() { + return mDelegateController.getDownloadDelegate(); + } + + /** + * Represents a download for <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads">downloads + * API</a> Instantiate using {@link WebExtensionController#createDownload} + */ + public static class Download { + /** + * Represents a unique identifier for the downloaded item that is persistent across browser + * sessions + */ + public final int id; + + /** + * For testing. + * + * @param id - integer id for the download item + */ + protected Download(final int id) { + this.id = id; + } + + /* package */ void setDelegate(final Delegate delegate) {} + + /** + * Updates the download state. This will trigger a call to <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/onChanged">downloads.onChanged</a> + * event to the corresponding `DownloadItem` on the extension side. + * + * @param data - current metadata associated with the download. {@link Download.Info} + * implementation instance + * @return GeckoResult with nothing or error inside + */ + @Nullable + @UiThread + public GeckoResult<Void> update(final @NonNull Download.Info data) { + final GeckoBundle bundle = new GeckoBundle(12); + + bundle.putInt("downloadItemId", this.id); + + bundle.putString("filename", data.filename()); + bundle.putString("mime", data.mime()); + bundle.putString("startTime", String.valueOf(data.startTime())); + bundle.putString("endTime", data.endTime() == null ? null : String.valueOf(data.endTime())); + bundle.putInt("state", data.state()); + bundle.putBoolean("canResume", data.canResume()); + bundle.putBoolean("paused", data.paused()); + final Integer error = data.error(); + if (error != null) { + bundle.putInt("error", error); + } + bundle.putLong("totalBytes", data.totalBytes()); + bundle.putLong("fileSize", data.fileSize()); + bundle.putBoolean("exists", data.fileExists()); + + return EventDispatcher.getInstance() + .queryVoid("GeckoView:WebExtension:DownloadChanged", bundle) + .map( + null, + e -> { + if (e instanceof EventDispatcher.QueryException) { + final EventDispatcher.QueryException queryException = + (EventDispatcher.QueryException) e; + if (queryException.data instanceof String) { + return new IllegalArgumentException((String) queryException.data); + } + } + return e; + }); + } + + /* package */ interface Delegate { + + default GeckoResult<Void> onPause( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onResume( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onCancel( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onErase( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onOpen( + final WebExtension source, final WebExtension.Download download) { + return null; + } + + default GeckoResult<Void> onRemoveFile( + final WebExtension source, final WebExtension.Download download) { + return null; + } + } + + /** + * Represents a download in progress where the app is currently receiving data from the server. + * See also {@link Info#state()}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({STATE_IN_PROGRESS, STATE_INTERRUPTED, STATE_COMPLETE}) + public @interface DownloadState {} + + /** Download is in progress. Default state */ + public static final int STATE_IN_PROGRESS = 0; + + /** An error broke the connection with the server. */ + public static final int STATE_INTERRUPTED = 1; + + /** The download completed successfully. */ + public static final int STATE_COMPLETE = 2; + + /** + * Represents a possible reason why a download was interrupted. See also {@link Info#error()}. + */ + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + INTERRUPT_REASON_NO_INTERRUPT, + INTERRUPT_REASON_FILE_FAILED, + INTERRUPT_REASON_FILE_ACCESS_DENIED, + INTERRUPT_REASON_FILE_NO_SPACE, + INTERRUPT_REASON_FILE_NAME_TOO_LONG, + INTERRUPT_REASON_FILE_TOO_LARGE, + INTERRUPT_REASON_FILE_VIRUS_INFECTED, + INTERRUPT_REASON_FILE_TRANSIENT_ERROR, + INTERRUPT_REASON_FILE_BLOCKED, + INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED, + INTERRUPT_REASON_FILE_TOO_SHORT, + INTERRUPT_REASON_NETWORK_FAILED, + INTERRUPT_REASON_NETWORK_TIMEOUT, + INTERRUPT_REASON_NETWORK_DISCONNECTED, + INTERRUPT_REASON_NETWORK_SERVER_DOWN, + INTERRUPT_REASON_NETWORK_INVALID_REQUEST, + INTERRUPT_REASON_SERVER_FAILED, + INTERRUPT_REASON_SERVER_NO_RANGE, + INTERRUPT_REASON_SERVER_BAD_CONTENT, + INTERRUPT_REASON_SERVER_UNAUTHORIZED, + INTERRUPT_REASON_SERVER_CERT_PROBLEM, + INTERRUPT_REASON_SERVER_FORBIDDEN, + INTERRUPT_REASON_USER_CANCELED, + INTERRUPT_REASON_USER_SHUTDOWN, + INTERRUPT_REASON_CRASH + }) + public @interface DownloadInterruptReason {} + + // File-related errors + public static final int INTERRUPT_REASON_NO_INTERRUPT = 0; + public static final int INTERRUPT_REASON_FILE_FAILED = 1; + public static final int INTERRUPT_REASON_FILE_ACCESS_DENIED = 2; + public static final int INTERRUPT_REASON_FILE_NO_SPACE = 3; + public static final int INTERRUPT_REASON_FILE_NAME_TOO_LONG = 4; + public static final int INTERRUPT_REASON_FILE_TOO_LARGE = 5; + public static final int INTERRUPT_REASON_FILE_VIRUS_INFECTED = 6; + public static final int INTERRUPT_REASON_FILE_TRANSIENT_ERROR = 7; + public static final int INTERRUPT_REASON_FILE_BLOCKED = 8; + public static final int INTERRUPT_REASON_FILE_SECURITY_CHECK_FAILED = 9; + public static final int INTERRUPT_REASON_FILE_TOO_SHORT = 10; + // Network-related errors + public static final int INTERRUPT_REASON_NETWORK_FAILED = 11; + public static final int INTERRUPT_REASON_NETWORK_TIMEOUT = 12; + public static final int INTERRUPT_REASON_NETWORK_DISCONNECTED = 13; + public static final int INTERRUPT_REASON_NETWORK_SERVER_DOWN = 14; + public static final int INTERRUPT_REASON_NETWORK_INVALID_REQUEST = 15; + // Server-related errors + public static final int INTERRUPT_REASON_SERVER_FAILED = 16; + public static final int INTERRUPT_REASON_SERVER_NO_RANGE = 17; + public static final int INTERRUPT_REASON_SERVER_BAD_CONTENT = 18; + public static final int INTERRUPT_REASON_SERVER_UNAUTHORIZED = 19; + public static final int INTERRUPT_REASON_SERVER_CERT_PROBLEM = 20; + public static final int INTERRUPT_REASON_SERVER_FORBIDDEN = 21; + // User-related errors + public static final int INTERRUPT_REASON_USER_CANCELED = 22; + public static final int INTERRUPT_REASON_USER_SHUTDOWN = 23; + // Miscellaneous + public static final int INTERRUPT_REASON_CRASH = 24; + + /** + * Interface for communicating the state of downloads to Web Extensions. See also <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/DownloadItem">WebExtensions/API/downloads/DownloadItem</a> + */ + public interface Info { + + /** + * @return A number representing the number of bytes received so far from the host during the + * download This does not take file compression into consideration + */ + @UiThread + default long bytesReceived() { + return 0; + } + + /** + * @return boolean indicating whether a currently-interrupted (e.g. paused) download can be + * resumed from the point where it was interrupted + */ + @UiThread + default boolean canResume() { + return false; + } + + /** + * @return A number representing the time when this download ended. This is null if the + * download has not yet finished. + */ + @Nullable + @UiThread + default Long endTime() { + return null; + } + + /** + * @return One of <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/InterruptReason">Interrupt + * Reason</a> constants denoting the error reason. + */ + @Nullable + @UiThread + default @DownloadInterruptReason Integer error() { + return null; + } + + /** + * @return the estimated number of milliseconds between the UNIX epoch and when this download + * is estimated to be completed. This is null if it is not known. + */ + @Nullable + @UiThread + default Long estimatedEndTime() { + return null; + } + + /** + * @return boolean indicating whether a downloaded file still exists + */ + @UiThread + default boolean fileExists() { + return false; + } + + /** + * @return the filename. + */ + @NonNull + @UiThread + default String filename() { + return ""; + } + + /** + * @return the total number of bytes in the whole file, after decompression. A value of -1 + * means that the total file size is unknown. + */ + @UiThread + default long fileSize() { + return -1; + } + + /** + * @return the downloaded file's MIME type + */ + @NonNull + @UiThread + default String mime() { + return ""; + } + + /** + * @return boolean indicating whether the download is paused i.e. if the download has stopped + * reading data from the host but has kept the connection open + */ + @UiThread + default boolean paused() { + return false; + } + + /** + * @return String representing the downloaded file's referrer + */ + @NonNull + @UiThread + default String referrer() { + return ""; + } + + /** + * @return the number of milliseconds between the UNIX epoch and when this download began + */ + @UiThread + default long startTime() { + return -1; + } + + /** + * @return a new state; one of the state constants to indicate whether the download is in + * progress, interrupted or complete + */ + @UiThread + default @DownloadState int state() { + return STATE_IN_PROGRESS; + } + + /** + * @return total number of bytes in the file being downloaded. This does not take file + * compression into consideration. A value of -1 here means that the total number of bytes + * is unknown + */ + @UiThread + default long totalBytes() { + return -1; + } + } + + @NonNull + /* package */ static GeckoBundle downloadInfoToBundle(final @NonNull Info data) { + final GeckoBundle dataBundle = new GeckoBundle(); + + dataBundle.putLong("bytesReceived", data.bytesReceived()); + dataBundle.putBoolean("canResume", data.canResume()); + dataBundle.putBoolean("exists", data.fileExists()); + dataBundle.putString("filename", data.filename()); + dataBundle.putLong("fileSize", data.fileSize()); + dataBundle.putString("mime", data.mime()); + dataBundle.putBoolean("paused", data.paused()); + dataBundle.putString("referrer", data.referrer()); + dataBundle.putString("startTime", String.valueOf(data.startTime())); + dataBundle.putInt("state", data.state()); + dataBundle.putLong("totalBytes", data.totalBytes()); + + final Long endTime = data.endTime(); + if (endTime != null) { + dataBundle.putString("endTime", endTime.toString()); + } + final Integer error = data.error(); + if (error != null) { + dataBundle.putInt("error", error); + } + final Long estimatedEndTime = data.estimatedEndTime(); + if (estimatedEndTime != null) { + dataBundle.putString("estimatedEndTime", estimatedEndTime.toString()); + } + + return dataBundle; + } + } + + /** Represents Web Extension API specific download request */ + public static class DownloadRequest { + /** Regular GeckoView {@link WebRequest} object */ + public final @NonNull WebRequest request; + + /** Optional fetch flags for {@link GeckoWebExecutor} */ + public final @GeckoWebExecutor.FetchFlags int downloadFlags; + + /** A file path relative to the default downloads directory */ + public final @Nullable String filename; + + /** + * The action you want taken if there is a filename conflict, as defined <a + * href="https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/downloads/FilenameConflictAction">here</a> + */ + public final @ConflictActionFlags int conflictActionFlag; + + /** + * Specifies whether to provide a file chooser dialog to allow the user to select a filename + * (true), or not (false) + */ + public final boolean saveAs; + + /** + * Flag that enables downloads to continue even if they encounter HTTP errors. When false, the + * download is canceled when it encounters an HTTP error. When true, the download continues when + * an HTTP error is encountered and the HTTP server error is not reported. However, if the + * download fails due to file-related, network-related, user-related, or other error, that error + * is reported. + */ + public final boolean allowHttpErrors; + + @Retention(RetentionPolicy.SOURCE) + @IntDef( + flag = true, + value = {CONFLICT_ACTION_UNIQUIFY, CONFLICT_ACTION_OVERWRITE, CONFLICT_ACTION_PROMPT}) + public @interface ConflictActionFlags {} + + /** The app should modify the filename to make it unique */ + public static final int CONFLICT_ACTION_UNIQUIFY = 0; + + /** The app should overwrite the old file with the newly-downloaded file */ + public static final int CONFLICT_ACTION_OVERWRITE = 1; + + /** The app should prompt the user, asking them to choose whether to uniquify or overwrite */ + public static final int CONFLICT_ACTION_PROMPT = 1 << 1; + + protected DownloadRequest(final DownloadRequest.Builder builder) { + this.request = builder.mRequest; + this.downloadFlags = builder.mDownloadFlags; + this.filename = builder.mFilename; + this.conflictActionFlag = builder.mConflictActionFlag; + this.saveAs = builder.mSaveAs; + this.allowHttpErrors = builder.mAllowHttpErrors; + } + + /** + * Convenience method to convert a GeckoBundle to a DownloadRequest. + * + * @param optionsBundle - in the shape of the options object browser.downloads.download() + * accepts + * @return request - a DownloadRequest instance + */ + /* package */ static DownloadRequest fromBundle(final GeckoBundle optionsBundle) { + final String uri = optionsBundle.getString("url"); + + final WebRequest.Builder mainRequestBuilder = new WebRequest.Builder(uri); + + final String method = optionsBundle.getString("method"); + if (method != null) { + mainRequestBuilder.method(method); + + if (method.equals("POST")) { + final String body = optionsBundle.getString("body"); + mainRequestBuilder.body(body); + } + } + + final GeckoBundle[] headers = optionsBundle.getBundleArray("headers"); + if (headers != null) { + for (final GeckoBundle header : headers) { + String value = header.getString("value"); + if (value == null) { + value = header.getString("binaryValue"); + } + mainRequestBuilder.addHeader(header.getString("name"), value); + } + } + + final WebRequest mainRequest = mainRequestBuilder.build(); + + int downloadFlags = GeckoWebExecutor.FETCH_FLAGS_NONE; + final boolean incognito = optionsBundle.getBoolean("incognito"); + if (incognito) { + downloadFlags |= GeckoWebExecutor.FETCH_FLAGS_PRIVATE; + } + + final boolean allowHttpErrors = optionsBundle.getBoolean("allowHttpErrors"); + + int conflictActionFlags = CONFLICT_ACTION_UNIQUIFY; + final String conflictActionString = optionsBundle.getString("conflictAction"); + if (conflictActionString != null) { + switch (conflictActionString.toLowerCase(Locale.ROOT)) { + case "overwrite": + conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_OVERWRITE; + break; + case "prompt": + conflictActionFlags |= WebExtension.DownloadRequest.CONFLICT_ACTION_PROMPT; + break; + } + } + + final boolean saveAs = optionsBundle.getBoolean("saveAs"); + + final WebExtension.DownloadRequest request = + new WebExtension.DownloadRequest.Builder(mainRequest) + .filename(optionsBundle.getString("filename")) + .downloadFlags(downloadFlags) + .conflictAction(conflictActionFlags) + .saveAs(saveAs) + .allowHttpErrors(allowHttpErrors) + .build(); + + return request; + } + + /* package */ static class Builder { + private final WebRequest mRequest; + private @GeckoWebExecutor.FetchFlags int mDownloadFlags = 0; + private String mFilename = null; + private @ConflictActionFlags int mConflictActionFlag = CONFLICT_ACTION_UNIQUIFY; + private boolean mSaveAs = false; + private boolean mAllowHttpErrors = false; + + /* package */ Builder(final WebRequest request) { + this.mRequest = request; + } + + /* package */ Builder downloadFlags(final @GeckoWebExecutor.FetchFlags int flags) { + this.mDownloadFlags = flags; + return this; + } + + /* package */ Builder filename(final String filename) { + this.mFilename = filename; + return this; + } + + /* package */ Builder conflictAction(final @ConflictActionFlags int conflictActionFlag) { + this.mConflictActionFlag = conflictActionFlag; + return this; + } + + /* package */ Builder saveAs(final boolean saveAs) { + this.mSaveAs = saveAs; + return this; + } + + /* package */ Builder allowHttpErrors(final boolean allowHttpErrors) { + this.mAllowHttpErrors = allowHttpErrors; + return this; + } + + /* package */ DownloadRequest build() { + return new DownloadRequest(this); + } + } + } + + /** Represents initial information on a download provided to Web Extension */ + public static class DownloadInitData { + @NonNull public final WebExtension.Download download; + @NonNull public final Download.Info initData; + + public DownloadInitData(final Download download, final Download.Info initData) { + this.download = download; + this.initData = initData; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java new file mode 100644 index 0000000000..edcd2b3e10 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebExtensionController.java @@ -0,0 +1,1359 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.annotation.SuppressLint; +import android.os.Build; +import android.util.Log; +import android.util.SparseArray; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.json.JSONException; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.MultiMap; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; + +public class WebExtensionController { + private static final String LOGTAG = "WebExtension"; + + private DebuggerDelegate mDebuggerDelegate; + private PromptDelegate mPromptDelegate; + private final WebExtension.Listener<WebExtension.TabDelegate> mListener; + + // Map [ (extensionId, nativeApp, session) -> message ] + private final MultiMap<MessageRecipient, Message> mPendingMessages; + private final MultiMap<String, Message> mPendingNewTab; + private final MultiMap<String, Message> mPendingBrowsingData; + private final MultiMap<String, Message> mPendingDownload; + + private final SparseArray<WebExtension.Download> mDownloads; + + private static class Message { + final GeckoBundle bundle; + final EventCallback callback; + final String event; + final GeckoSession session; + + public Message( + final String event, + final GeckoBundle bundle, + final EventCallback callback, + final GeckoSession session) { + this.bundle = bundle; + this.callback = callback; + this.event = event; + this.session = session; + } + } + + private static class ExtensionStore { + private final Map<String, WebExtension> mData = new HashMap<>(); + private Observer mObserver; + + interface Observer { + /** + * * This event is fired every time a new extension object is created by the store. + * + * @param extension the newly-created extension object + */ + WebExtension onNewExtension(final GeckoBundle extension); + } + + public GeckoResult<WebExtension> get(final String id) { + final WebExtension extension = mData.get(id); + if (extension != null) { + return GeckoResult.fromValue(extension); + } + + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("extensionId", id); + + final GeckoResult<WebExtension> pending = + EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Get", bundle) + .map( + extensionBundle -> { + final WebExtension ext = mObserver.onNewExtension(extensionBundle); + mData.put(ext.id, ext); + return ext; + }); + + return pending; + } + + public void setObserver(final Observer observer) { + mObserver = observer; + } + + public void remove(final String id) { + mData.remove(id); + } + + /** + * Add this extension to the store and update it's current value if it's already present. + * + * @param id the {@link WebExtension} id. + * @param extension the {@link WebExtension} to add to the store. + */ + public void update(final String id, final WebExtension extension) { + mData.put(id, extension); + } + } + + private ExtensionStore mExtensions = new ExtensionStore(); + + private Internals mInternals = new Internals(); + + // Avoids exposing listeners to the API + private class Internals implements BundleEventListener, ExtensionStore.Observer { + @Override + // BundleEventListener + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + WebExtensionController.this.handleMessage(event, message, callback, null); + } + + @Override + public WebExtension onNewExtension(final GeckoBundle bundle) { + return WebExtension.fromBundle(mDelegateControllerProvider, bundle); + } + } + + /* package */ void releasePendingMessages( + final WebExtension extension, final String nativeApp, final GeckoSession session) { + Log.i( + LOGTAG, + "releasePendingMessages:" + + " extension=" + + extension.id + + " nativeApp=" + + nativeApp + + " session=" + + session); + final List<Message> messages = + mPendingMessages.remove(new MessageRecipient(nativeApp, extension.id, session)); + if (messages == null) { + return; + } + + for (final Message message : messages) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + } + + private class DelegateController implements WebExtension.DelegateController { + private final WebExtension mExtension; + + public DelegateController(final WebExtension extension) { + mExtension = extension; + } + + @Override + public void onMessageDelegate( + final String nativeApp, final WebExtension.MessageDelegate delegate) { + mListener.setMessageDelegate(mExtension, delegate, nativeApp); + } + + @Override + public void onActionDelegate(final WebExtension.ActionDelegate delegate) { + mListener.setActionDelegate(mExtension, delegate); + } + + @Override + public WebExtension.ActionDelegate getActionDelegate() { + return mListener.getActionDelegate(mExtension); + } + + @Override + public void onBrowsingDataDelegate(final WebExtension.BrowsingDataDelegate delegate) { + mListener.setBrowsingDataDelegate(mExtension, delegate); + + for (final Message message : mPendingBrowsingData.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingBrowsingData.remove(mExtension.id); + } + + @Override + public WebExtension.BrowsingDataDelegate getBrowsingDataDelegate() { + return mListener.getBrowsingDataDelegate(mExtension); + } + + @Override + public void onTabDelegate(final WebExtension.TabDelegate delegate) { + mListener.setTabDelegate(mExtension, delegate); + + for (final Message message : mPendingNewTab.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingNewTab.remove(mExtension.id); + } + + @Override + public WebExtension.TabDelegate getTabDelegate() { + return mListener.getTabDelegate(mExtension); + } + + @Override + public void onDownloadDelegate(final WebExtension.DownloadDelegate delegate) { + mListener.setDownloadDelegate(mExtension, delegate); + + for (final Message message : mPendingDownload.get(mExtension.id)) { + WebExtensionController.this.handleMessage( + message.event, message.bundle, message.callback, message.session); + } + + mPendingDownload.remove(mExtension.id); + } + + @Override + public WebExtension.DownloadDelegate getDownloadDelegate() { + return mListener.getDownloadDelegate(mExtension); + } + } + + final WebExtension.DelegateControllerProvider mDelegateControllerProvider = + new WebExtension.DelegateControllerProvider() { + @Override + public WebExtension.DelegateController controllerFor(final WebExtension extension) { + return new DelegateController(extension); + } + }; + + /** + * This delegate will be called whenever an extension is about to be installed or it needs new + * permissions, e.g during an update or because it called <code>permissions.request</code> + */ + @UiThread + public interface PromptDelegate { + /** + * Called whenever a new extension is being installed. This is intended as an opportunity for + * the app to prompt the user for the permissions required by this extension. + * + * @param extension The {@link WebExtension} that is about to be installed. You can use {@link + * WebExtension#metaData} to gather information about this extension when building the user + * prompt dialog. + * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if + * this extension should be installed or {@link AllowOrDeny#DENY DENY} if this extension + * should not be installed. A null value will be interpreted as {@link AllowOrDeny#DENY + * DENY}. + */ + @Nullable + default GeckoResult<AllowOrDeny> onInstallPrompt(final @NonNull WebExtension extension) { + return null; + } + + /** + * Called whenever an updated extension has new permissions. This is intended as an opportunity + * for the app to prompt the user for the new permissions required by this extension. + * + * @param currentlyInstalled The {@link WebExtension} that is currently installed. + * @param updatedExtension The {@link WebExtension} that will replace the previous extension. + * @param newPermissions The new permissions that are needed. + * @param newOrigins The new origins that are needed. + * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if + * this extension should be update or {@link AllowOrDeny#DENY DENY} if this extension should + * not be update. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}. + */ + @Nullable + default GeckoResult<AllowOrDeny> onUpdatePrompt( + @NonNull final WebExtension currentlyInstalled, + @NonNull final WebExtension updatedExtension, + @NonNull final String[] newPermissions, + @NonNull final String[] newOrigins) { + return null; + } + + /** + * Called whenever permissions are requested. This is intended as an opportunity for the app to + * prompt the user for the permissions required by this extension at runtime. + * + * @param extension The {@link WebExtension} that is about to be installed. You can use {@link + * WebExtension#metaData} to gather information about this extension when building the user + * prompt dialog. + * @param permissions The permissions that are requested. + * @param origins The requested host permissions. + * @return A {@link GeckoResult} that completes to either {@link AllowOrDeny#ALLOW ALLOW} if the + * request should be approved or {@link AllowOrDeny#DENY DENY} if the request should be + * denied. A null value will be interpreted as {@link AllowOrDeny#DENY DENY}. + */ + @Nullable + default GeckoResult<AllowOrDeny> onOptionalPrompt( + final @NonNull WebExtension extension, + final @NonNull String[] permissions, + final @NonNull String[] origins) { + return null; + } + } + + public interface DebuggerDelegate { + /** + * Called whenever the list of installed extensions has been modified using the debugger with + * tools like web-ext. + * + * <p>This is intended as an opportunity to refresh the list of installed extensions using + * {@link WebExtensionController#list} and to set delegates on the new {@link WebExtension} + * objects, e.g. using {@link WebExtension#setActionDelegate} and {@link + * WebExtension#setMessageDelegate}. + * + * @see <a + * href="https://extensionworkshop.com/documentation/develop/getting-started-with-web-ext"> + * Getting started with web-ext</a> + */ + @UiThread + default void onExtensionListUpdated() {} + } + + /** + * @return the current {@link PromptDelegate} instance. + * @see PromptDelegate + */ + @UiThread + @Nullable + public PromptDelegate getPromptDelegate() { + return mPromptDelegate; + } + + /** + * Set the {@link PromptDelegate} for this instance. This delegate will be used to be notified + * whenever an extension is being installed or needs new permissions. + * + * @param delegate the delegate instance. + * @see PromptDelegate + */ + @UiThread + public void setPromptDelegate(final @Nullable PromptDelegate delegate) { + if (delegate == null && mPromptDelegate != null) { + EventDispatcher.getInstance() + .unregisterUiThreadListener( + mInternals, + "GeckoView:WebExtension:InstallPrompt", + "GeckoView:WebExtension:UpdatePrompt", + "GeckoView:WebExtension:OptionalPrompt"); + } else if (delegate != null && mPromptDelegate == null) { + EventDispatcher.getInstance() + .registerUiThreadListener( + mInternals, + "GeckoView:WebExtension:InstallPrompt", + "GeckoView:WebExtension:UpdatePrompt", + "GeckoView:WebExtension:OptionalPrompt"); + } + + mPromptDelegate = delegate; + } + + /** + * Set the {@link DebuggerDelegate} for this instance. This delegate will receive updates about + * extension changes using developer tools. + * + * @param delegate the Delegate instance + */ + @UiThread + public void setDebuggerDelegate(final @NonNull DebuggerDelegate delegate) { + if (delegate == null && mDebuggerDelegate != null) { + EventDispatcher.getInstance() + .unregisterUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated"); + } else if (delegate != null && mDebuggerDelegate == null) { + EventDispatcher.getInstance() + .registerUiThreadListener(mInternals, "GeckoView:WebExtension:DebuggerListUpdated"); + } + + mDebuggerDelegate = delegate; + } + + private static class InstallCanceller implements GeckoResult.CancellationDelegate { + public final String installId; + + public InstallCanceller() { + installId = UUID.randomUUID().toString(); + } + + @Override + public GeckoResult<Boolean> cancel() { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("installId", installId); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:CancelInstall", bundle) + .map(response -> response.getBoolean("cancelled")); + } + } + + /** + * Install an extension. + * + * <p>An installed extension will persist and will be available even when restarting the {@link + * GeckoRuntime}. + * + * <p>Installed extensions through this method need to be signed by Mozilla, see <a + * href="https://extensionworkshop.com/documentation/publish/signing-and-distribution-overview/#distributing-your-addon"> + * Distributing your add-on </a>. + * + * <p>When calling this method, the GeckoView library will download the extension, validate its + * manifest and signature, and give you an opportunity to verify its permissions through {@link + * PromptDelegate#installPrompt}, you can use this method to prompt the user if appropriate. + * + * @param uri URI to the extension's <code>.xpi</code> package. This can be a remote <code>https: + * </code> URI or a local <code>file:</code> or <code>resource:</code> URI. Note: the app + * needs the appropriate permissions for local URIs. + * @return A {@link GeckoResult} that will complete when the installation process finishes. For + * successful installations, the GeckoResult will return the {@link WebExtension} object that + * you can use to set delegates and retrieve information about the WebExtension using {@link + * WebExtension#metaData}. + * <p>If an error occurs during the installation process, the GeckoResult will complete + * exceptionally with a {@link WebExtension.InstallException InstallException} that will + * contain the relevant error code in {@link WebExtension.InstallException#code + * InstallException#code}. + * @see PromptDelegate#installPrompt + * @see WebExtension.InstallException.ErrorCodes + * @see WebExtension#metaData + */ + @NonNull + @AnyThread + public GeckoResult<WebExtension> install(final @NonNull String uri) { + final InstallCanceller canceller = new InstallCanceller(); + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("locationUri", uri); + bundle.putString("installId", canceller.installId); + + final GeckoResult<WebExtension> result = + EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Install", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + result.setCancellationDelegate(canceller); + return result; + } + + /** + * Set whether an extension should be allowed to run in private browsing or not. + * + * @param extension the {@link WebExtension} instance to modify. + * @param allowed true if this extension should be allowed to run in private browsing pages, false + * otherwise. + * @return the updated {@link WebExtension} instance. + */ + @NonNull + @AnyThread + public GeckoResult<WebExtension> setAllowedInPrivateBrowsing( + final @NonNull WebExtension extension, final boolean allowed) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("extensionId", extension.id); + bundle.putBoolean("allowed", allowed); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:SetPBAllowed", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + /** + * Install a built-in extension. + * + * <p>Built-in extensions have access to native messaging, don't need to be signed and are + * installed from a folder in the APK instead of a .xpi bundle. + * + * <p>Example: + * + * <p><code> + * controller.installBuiltIn("resource://android/assets/example/"); + * </code> Will install the built-in extension located at <code>/assets/example/</code> in the + * app's APK. + * + * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only + * <code>resource://android</code> URIs are allowed. + * @see WebExtension.MessageDelegate + * @return A {@link GeckoResult} that completes with the extension once it's installed. + */ + @NonNull + @AnyThread + public GeckoResult<WebExtension> installBuiltIn(final @NonNull String uri) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("locationUri", uri); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:InstallBuiltIn", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /** + * Ensure that a built-in extension is installed. + * + * <p>Similar to {@link #installBuiltIn}, except the extension is not re-installed if it's already + * present and it has the same version. + * + * <p>Example: + * + * <p><code> + * controller.ensureBuiltIn("resource://android/assets/example/", "example@example.com"); + * </code> Will install the built-in extension located at <code>/assets/example/</code> in the + * app's APK. + * + * @param uri Folder where the extension is located. To ensure this folder is inside the APK, only + * <code>resource://android</code> URIs are allowed. + * @param id Extension ID as present in the manifest.json file. + * @see WebExtension.MessageDelegate + * @return A {@link GeckoResult} that completes with the extension once it's installed. + */ + @NonNull + @AnyThread + public GeckoResult<WebExtension> ensureBuiltIn( + final @NonNull String uri, final @Nullable String id) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("locationUri", uri); + bundle.putString("webExtensionId", id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:EnsureBuiltIn", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /** + * Uninstall an extension. + * + * <p>Uninstalling an extension will remove it from the current {@link GeckoRuntime} instance, + * delete all its data and trigger a request to close all extension pages currently open. + * + * @param extension The {@link WebExtension} to be uninstalled. + * @return A {@link GeckoResult} that will complete when the uninstall process is completed. + */ + @NonNull + @AnyThread + public GeckoResult<Void> uninstall(final @NonNull WebExtension extension) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("webExtensionId", extension.id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Uninstall", bundle) + .accept(result -> unregisterWebExtension(extension)); + } + + @Retention(RetentionPolicy.SOURCE) + @IntDef({EnableSource.USER, EnableSource.APP}) + public @interface EnableSources {} + + /** + * Contains the possible values for the <code>source</code> parameter in {@link #enable} and + * {@link #disable}. + */ + public static class EnableSource { + /** Action has been requested by the user. */ + public static final int USER = 1; + + /** + * Action requested by the app itself, e.g. to disable an extension that is not supported in + * this version of the app. + */ + public static final int APP = 2; + + static String toString(final @EnableSources int flag) { + if (flag == USER) { + return "user"; + } else if (flag == APP) { + return "app"; + } else { + throw new IllegalArgumentException("Value provided in flags is not valid."); + } + } + } + + /** + * Enable an extension that has been disabled. If the extension is already enabled, this method + * has no effect. + * + * @param extension The {@link WebExtension} to be enabled. + * @param source The agent that initiated this action, e.g. if the action has been initiated by + * the user,use {@link EnableSource#USER}. + * @return the new {@link WebExtension} instance, updated to reflect the enablement. + */ + @AnyThread + @NonNull + public GeckoResult<WebExtension> enable( + final @NonNull WebExtension extension, final @EnableSources int source) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("webExtensionId", extension.id); + bundle.putString("source", EnableSource.toString(source)); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Enable", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + /** + * Disable an extension that is enabled. If the extension is already disabled, this method has no + * effect. + * + * @param extension The {@link WebExtension} to be disabled. + * @param source The agent that initiated this action, e.g. if the action has been initiated by + * the user, use {@link EnableSource#USER}. + * @return the new {@link WebExtension} instance, updated to reflect the disablement. + */ + @AnyThread + @NonNull + public GeckoResult<WebExtension> disable( + final @NonNull WebExtension extension, final @EnableSources int source) { + final GeckoBundle bundle = new GeckoBundle(2); + bundle.putString("webExtensionId", extension.id); + bundle.putString("source", EnableSource.toString(source)); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Disable", bundle) + .map(ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext)) + .map(this::registerWebExtension); + } + + private List<WebExtension> listFromBundle(final GeckoBundle response) { + final GeckoBundle[] bundles = response.getBundleArray("extensions"); + final List<WebExtension> list = new ArrayList<>(bundles.length); + + for (final GeckoBundle bundle : bundles) { + final WebExtension extension = new WebExtension(mDelegateControllerProvider, bundle); + list.add(registerWebExtension(extension)); + } + + return list; + } + + /** + * List installed extensions for this {@link GeckoRuntime}. + * + * <p>The returned list can be used to set delegates on the {@link WebExtension} objects using + * {@link WebExtension#setActionDelegate}, {@link WebExtension#setMessageDelegate}. + * + * @return a {@link GeckoResult} that will resolve when the list of extensions is available. + */ + @AnyThread + @NonNull + public GeckoResult<List<WebExtension>> list() { + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:List") + .map(this::listFromBundle); + } + + /** + * Update a web extension. + * + * <p>When checking for an update, GeckoView will download the update manifest that is defined by + * the web extension's manifest property <a + * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/">browser_specific_settings.gecko.update_url</a>. + * If an update is found it will be downloaded and installed. If the extension needs any new + * permissions the {@link PromptDelegate#updatePrompt} will be triggered. + * + * <p>More information about the update manifest format is available <a + * href="https://extensionworkshop.com/documentation/manage/updating-your-extension/#manifest-structure">here</a>. + * + * @param extension The extension to update. + * @return A {@link GeckoResult} that will complete when the update process finishes. If an update + * is found and installed successfully, the GeckoResult will return the updated {@link + * WebExtension}. If no update is available, null will be returned. If the updated extension + * requires new permissions, the {@link PromptDelegate#installPrompt} will be called. + * @see PromptDelegate#updatePrompt + */ + @AnyThread + @NonNull + public GeckoResult<WebExtension> update(final @NonNull WebExtension extension) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putString("webExtensionId", extension.id); + + return EventDispatcher.getInstance() + .queryBundle("GeckoView:WebExtension:Update", bundle) + .map( + ext -> WebExtension.fromBundle(mDelegateControllerProvider, ext), + WebExtension.InstallException::fromQueryException) + .map(this::registerWebExtension); + } + + /* package */ WebExtensionController(final GeckoRuntime runtime) { + mListener = new WebExtension.Listener<>(runtime); + mPendingMessages = new MultiMap<>(); + mPendingNewTab = new MultiMap<>(); + mPendingBrowsingData = new MultiMap<>(); + mPendingDownload = new MultiMap<>(); + mExtensions.setObserver(mInternals); + mDownloads = new SparseArray<>(); + } + + /* package */ WebExtension registerWebExtension(final WebExtension webExtension) { + if (webExtension != null) { + mExtensions.update(webExtension.id, webExtension); + } + return webExtension; + } + + /* package */ void handleMessage( + final String event, + final GeckoBundle bundle, + final EventCallback callback, + final GeckoSession session) { + final Message message = new Message(event, bundle, callback, session); + + Log.d(LOGTAG, "handleMessage " + event); + + if ("GeckoView:WebExtension:InstallPrompt".equals(event)) { + installPrompt(bundle, callback); + return; + } else if ("GeckoView:WebExtension:UpdatePrompt".equals(event)) { + updatePrompt(bundle, callback); + return; + } else if ("GeckoView:WebExtension:DebuggerListUpdated".equals(event)) { + if (mDebuggerDelegate != null) { + mDebuggerDelegate.onExtensionListUpdated(); + } + return; + } + + extensionFromBundle(bundle) + .accept( + extension -> { + if ("GeckoView:WebExtension:NewTab".equals(event)) { + newTab(message, extension); + return; + } else if ("GeckoView:WebExtension:UpdateTab".equals(event)) { + updateTab(message, extension); + return; + } else if ("GeckoView:WebExtension:CloseTab".equals(event)) { + closeTab(message, extension); + return; + } else if ("GeckoView:BrowserAction:Update".equals(event)) { + actionUpdate(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION); + return; + } else if ("GeckoView:PageAction:Update".equals(event)) { + actionUpdate(message, extension, WebExtension.Action.TYPE_PAGE_ACTION); + return; + } else if ("GeckoView:BrowserAction:OpenPopup".equals(event)) { + openPopup(message, extension, WebExtension.Action.TYPE_BROWSER_ACTION); + return; + } else if ("GeckoView:PageAction:OpenPopup".equals(event)) { + openPopup(message, extension, WebExtension.Action.TYPE_PAGE_ACTION); + return; + } else if ("GeckoView:WebExtension:OpenOptionsPage".equals(event)) { + openOptionsPage(message, extension); + return; + } else if ("GeckoView:BrowsingData:GetSettings".equals(event)) { + getSettings(message, extension); + return; + } else if ("GeckoView:BrowsingData:Clear".equals(event)) { + browsingDataClear(message, extension); + return; + } else if ("GeckoView:WebExtension:Download".equals(event)) { + download(message, extension); + return; + } else if ("GeckoView:WebExtension:OptionalPrompt".equals(event)) { + optionalPrompt(message, extension); + return; + } + + // GeckoView:WebExtension:Connect and GeckoView:WebExtension:Message + // are handled below. + final String nativeApp = bundle.getString("nativeApp"); + if (nativeApp == null) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing required nativeApp message parameter."); + } + callback.sendError("Missing nativeApp parameter."); + return; + } + + final GeckoBundle senderBundle = bundle.getBundle("sender"); + final WebExtension.MessageSender sender = + fromBundle(extension, senderBundle, session); + if (sender == null) { + if (callback != null) { + if (BuildConfig.DEBUG_BUILD) { + try { + Log.e( + LOGTAG, "Could not find recipient for message: " + bundle.toJSONObject()); + } catch (final JSONException ex) { + } + } + callback.sendError("Could not find recipient for " + bundle.getBundle("sender")); + } + return; + } + + if ("GeckoView:WebExtension:Connect".equals(event)) { + connect(nativeApp, bundle.getLong("portId", -1), message, sender); + } else if ("GeckoView:WebExtension:Message".equals(event)) { + message(nativeApp, message, sender); + } + }); + } + + private void installPrompt(final GeckoBundle message, final EventCallback callback) { + final GeckoBundle extensionBundle = message.getBundle("extension"); + if (extensionBundle == null + || !extensionBundle.containsKey("webExtensionId") + || !extensionBundle.containsKey("locationURI")) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing webExtensionId or locationURI"); + } + + Log.e(LOGTAG, "Missing webExtensionId or locationURI"); + return; + } + + final WebExtension extension = new WebExtension(mDelegateControllerProvider, extensionBundle); + + if (mPromptDelegate == null) { + Log.e( + LOGTAG, "Tried to install extension " + extension.id + " but no delegate is registered"); + return; + } + + final GeckoResult<AllowOrDeny> promptResponse = mPromptDelegate.onInstallPrompt(extension); + if (promptResponse == null) { + return; + } + + callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + private void updatePrompt(final GeckoBundle message, final EventCallback callback) { + final GeckoBundle currentBundle = message.getBundle("currentlyInstalled"); + final GeckoBundle updatedBundle = message.getBundle("updatedExtension"); + final String[] newPermissions = message.getStringArray("newPermissions"); + final String[] newOrigins = message.getStringArray("newOrigins"); + if (currentBundle == null || updatedBundle == null) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing bundle"); + } + + Log.e(LOGTAG, "Missing bundle"); + return; + } + + final WebExtension currentExtension = + new WebExtension(mDelegateControllerProvider, currentBundle); + + final WebExtension updatedExtension = + new WebExtension(mDelegateControllerProvider, updatedBundle); + + if (mPromptDelegate == null) { + Log.e( + LOGTAG, + "Tried to update extension " + currentExtension.id + " but no delegate is registered"); + return; + } + + final GeckoResult<AllowOrDeny> promptResponse = + mPromptDelegate.onUpdatePrompt( + currentExtension, updatedExtension, newPermissions, newOrigins); + if (promptResponse == null) { + return; + } + + callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + private void optionalPrompt(final Message message, final WebExtension extension) { + if (mPromptDelegate == null) { + Log.e( + LOGTAG, + "Tried to request optional permissions for extension " + + extension.id + + " but no delegate is registered"); + return; + } + + final String[] permissions = + message.bundle.getBundle("permissions").getStringArray("permissions"); + final String[] origins = message.bundle.getBundle("permissions").getStringArray("origins"); + final GeckoResult<AllowOrDeny> promptResponse = + mPromptDelegate.onOptionalPrompt(extension, permissions, origins); + if (promptResponse == null) { + return; + } + + message.callback.resolveTo( + promptResponse.map( + allowOrDeny -> { + final GeckoBundle response = new GeckoBundle(1); + response.putBoolean("allow", AllowOrDeny.ALLOW.equals(allowOrDeny)); + return response; + })); + } + + @SuppressLint("WrongThread") // for .toGeckoBundle + private void getSettings(final Message message, final WebExtension extension) { + final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension); + if (delegate == null) { + mPendingBrowsingData.add(extension.id, message); + return; + } + + final GeckoResult<WebExtension.BrowsingDataDelegate.Settings> settingsResult = + delegate.onGetSettings(); + if (settingsResult == null) { + message.callback.sendError("browsingData.settings is not supported"); + return; + } + message.callback.resolveTo(settingsResult.map(settings -> settings.toGeckoBundle())); + } + + private void browsingDataClear(final Message message, final WebExtension extension) { + final WebExtension.BrowsingDataDelegate delegate = mListener.getBrowsingDataDelegate(extension); + if (delegate == null) { + mPendingBrowsingData.add(extension.id, message); + return; + } + + final long unixTimestamp = message.bundle.getLong("since"); + final String dataType = message.bundle.getString("dataType"); + + final GeckoResult<Void> response; + if ("downloads".equals(dataType)) { + response = delegate.onClearDownloads(unixTimestamp); + } else if ("formData".equals(dataType)) { + response = delegate.onClearFormData(unixTimestamp); + } else if ("history".equals(dataType)) { + response = delegate.onClearHistory(unixTimestamp); + } else if ("passwords".equals(dataType)) { + response = delegate.onClearPasswords(unixTimestamp); + } else { + throw new IllegalStateException("Illegal clear data type: " + dataType); + } + + message.callback.resolveTo(response); + } + + /* package */ void download(final Message message, final WebExtension extension) { + final WebExtension.DownloadDelegate delegate = mListener.getDownloadDelegate(extension); + if (delegate == null) { + mPendingDownload.add(extension.id, message); + return; + } + + final GeckoBundle optionsBundle = message.bundle.getBundle("options"); + + final WebExtension.DownloadRequest request = + WebExtension.DownloadRequest.fromBundle(optionsBundle); + + final GeckoResult<WebExtension.DownloadInitData> result = + delegate.onDownload(extension, request); + if (result == null) { + message.callback.sendError("downloads.download is not supported"); + return; + } + + message.callback.resolveTo( + result.map( + value -> { + if (value == null) { + Log.e(LOGTAG, "onDownload returned invalid null value"); + throw new IllegalArgumentException("downloads.download is not supported"); + } + + final GeckoBundle returnMessage = + WebExtension.Download.downloadInfoToBundle(value.initData); + returnMessage.putInt("id", value.download.id); + + return returnMessage; + })); + } + + /* package */ void openOptionsPage(final Message message, final WebExtension extension) { + final GeckoBundle bundle = message.bundle; + final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension); + + if (delegate != null) { + delegate.onOpenOptionsPage(extension); + } else { + message.callback.sendError("runtime.openOptionsPage is not supported"); + } + + message.callback.sendSuccess(null); + } + + /* package */ + @SuppressLint("WrongThread") // for .isOpen + void newTab(final Message message, final WebExtension extension) { + final GeckoBundle bundle = message.bundle; + + final WebExtension.TabDelegate delegate = mListener.getTabDelegate(extension); + final WebExtension.CreateTabDetails details = + new WebExtension.CreateTabDetails(bundle.getBundle("createProperties")); + + final GeckoResult<GeckoSession> result; + if (delegate != null) { + result = delegate.onNewTab(extension, details); + } else { + mPendingNewTab.add(extension.id, message); + return; + } + + if (result == null) { + message.callback.sendSuccess(false); + return; + } + + final String newSessionId = message.bundle.getString("newSessionId"); + message.callback.resolveTo( + result.map( + session -> { + if (session == null) { + return false; + } + + if (session.isOpen()) { + throw new IllegalArgumentException("Must use an unopened GeckoSession instance"); + } + + session.open(mListener.runtime, newSessionId); + return true; + })); + } + + /* package */ void updateTab(final Message message, final WebExtension extension) { + final WebExtension.SessionTabDelegate delegate = + message.session.getWebExtensionController().getTabDelegate(extension); + final EventCallback callback = message.callback; + + if (delegate == null) { + callback.sendError("tabs.update is not supported"); + return; + } + + final WebExtension.UpdateTabDetails details = + new WebExtension.UpdateTabDetails(message.bundle.getBundle("updateProperties")); + callback.resolveTo( + delegate + .onUpdateTab(extension, message.session, details) + .map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return null; + } else { + throw new Exception("tabs.update is not supported"); + } + })); + } + + /* package */ void closeTab(final Message message, final WebExtension extension) { + final WebExtension.SessionTabDelegate delegate = + message.session.getWebExtensionController().getTabDelegate(extension); + + final GeckoResult<AllowOrDeny> result; + if (delegate != null) { + result = delegate.onCloseTab(extension, message.session); + } else { + result = GeckoResult.fromValue(AllowOrDeny.DENY); + } + + message.callback.resolveTo( + result.map( + value -> { + if (value == AllowOrDeny.ALLOW) { + return null; + } else { + throw new Exception("tabs.remove is not supported"); + } + })); + } + + /** + * Notifies extensions about a active tab change over the `tabs.onActivated` event. + * + * @param session The {@link GeckoSession} of the newly selected session/tab. + * @param active true if the tab became active, false if the tab became inactive. + */ + @AnyThread + public void setTabActive(@NonNull final GeckoSession session, final boolean active) { + final GeckoBundle bundle = new GeckoBundle(1); + bundle.putBoolean("active", active); + session.getEventDispatcher().dispatch("GeckoView:WebExtension:SetTabActive", bundle); + } + + /* package */ void unregisterWebExtension(final WebExtension webExtension) { + mExtensions.remove(webExtension.id); + mListener.unregisterWebExtension(webExtension); + } + + private WebExtension.MessageSender fromBundle( + final WebExtension extension, final GeckoBundle sender, final GeckoSession session) { + if (extension == null) { + // All senders should have an extension + return null; + } + + final String envType = sender.getString("envType"); + @WebExtension.MessageSender.EnvType final int environmentType; + + if ("content_child".equals(envType)) { + environmentType = WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT; + } else if ("addon_child".equals(envType)) { + // TODO Bug 1554277: check that this message is coming from the right process + environmentType = WebExtension.MessageSender.ENV_TYPE_EXTENSION; + } else { + environmentType = WebExtension.MessageSender.ENV_TYPE_UNKNOWN; + } + + if (environmentType == WebExtension.MessageSender.ENV_TYPE_UNKNOWN) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException("Missing or unknown envType: " + envType); + } + + return null; + } + + final String url = sender.getString("url"); + final boolean isTopLevel; + if (session == null || environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) { + // This message is coming from the background page, a popup, or an extension page + isTopLevel = true; + } else { + // If session is present we are either receiving this message from a content script or + // an extension page, let's make sure we have the proper identification so that + // embedders can check the origin of this message. + // -1 is an invalid frame id + final boolean hasFrameId = + sender.containsKey("frameId") && sender.getInt("frameId", -1) != -1; + final boolean hasUrl = sender.containsKey("url"); + if (!hasFrameId || !hasUrl) { + if (BuildConfig.DEBUG_BUILD) { + throw new RuntimeException( + "Missing sender information. hasFrameId: " + hasFrameId + " hasUrl: " + hasUrl); + } + + // This message does not have the proper identification and may be compromised, + // let's ignore it. + return null; + } + + isTopLevel = sender.getInt("frameId", -1) == 0; + } + + return new WebExtension.MessageSender(extension, session, url, environmentType, isTopLevel); + } + + private WebExtension.MessageDelegate getDelegate( + final String nativeApp, + final WebExtension.MessageSender sender, + final EventCallback callback) { + if ((sender.webExtension.flags & WebExtension.Flags.ALLOW_CONTENT_MESSAGING) == 0 + && sender.environmentType == WebExtension.MessageSender.ENV_TYPE_CONTENT_SCRIPT) { + callback.sendError("This NativeApp can't receive messages from Content Scripts."); + return null; + } + + WebExtension.MessageDelegate delegate = null; + + if (sender.session != null) { + delegate = + sender + .session + .getWebExtensionController() + .getMessageDelegate(sender.webExtension, nativeApp); + } else if (sender.environmentType == WebExtension.MessageSender.ENV_TYPE_EXTENSION) { + delegate = mListener.getMessageDelegate(sender.webExtension, nativeApp); + } + + return delegate; + } + + private static class MessageRecipient { + public final String webExtensionId; + public final String nativeApp; + public final GeckoSession session; + + public MessageRecipient( + final String webExtensionId, final String nativeApp, final GeckoSession session) { + this.webExtensionId = webExtensionId; + this.nativeApp = nativeApp; + this.session = session; + } + + private static boolean equals(final Object a, final Object b) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + return Objects.equals(a, b); + } + + return (a == b) || (a != null && a.equals(b)); + } + + @Override + public boolean equals(final Object other) { + if (!(other instanceof MessageRecipient)) { + return false; + } + + final MessageRecipient o = (MessageRecipient) other; + return equals(webExtensionId, o.webExtensionId) + && equals(nativeApp, o.nativeApp) + && equals(session, o.session); + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {webExtensionId, nativeApp, session}); + } + } + + private void connect( + final String nativeApp, + final long portId, + final Message message, + final WebExtension.MessageSender sender) { + if (portId == -1) { + message.callback.sendError("Missing portId."); + return; + } + + final WebExtension.Port port = new WebExtension.Port(nativeApp, portId, sender); + + final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, message.callback); + if (delegate == null) { + mPendingMessages.add( + new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message); + return; + } + + delegate.onConnect(port); + message.callback.sendSuccess(true); + } + + private void message( + final String nativeApp, final Message message, final WebExtension.MessageSender sender) { + final EventCallback callback = message.callback; + + final Object content; + try { + content = message.bundle.toJSONObject().get("data"); + } catch (final JSONException ex) { + callback.sendError(ex.getMessage()); + return; + } + + final WebExtension.MessageDelegate delegate = getDelegate(nativeApp, sender, callback); + if (delegate == null) { + mPendingMessages.add( + new MessageRecipient(nativeApp, sender.webExtension.id, sender.session), message); + return; + } + + final GeckoResult<Object> response = delegate.onMessage(nativeApp, content, sender); + if (response == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo(response); + } + + private GeckoResult<WebExtension> extensionFromBundle(final GeckoBundle message) { + final String extensionId = message.getString("extensionId"); + return mExtensions.get(extensionId); + } + + private void openPopup( + final Message message, + final WebExtension extension, + final @WebExtension.Action.ActionType int actionType) { + if (extension == null) { + return; + } + + final WebExtension.Action action = + new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension); + final String popupUri = message.bundle.getString("popupUri"); + + final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session); + if (delegate == null) { + return; + } + + final GeckoResult<GeckoSession> popup = delegate.onOpenPopup(extension, action); + action.openPopup(popup, popupUri); + } + + private WebExtension.ActionDelegate actionDelegateFor( + final WebExtension extension, final GeckoSession session) { + if (session == null) { + return mListener.getActionDelegate(extension); + } + + return session.getWebExtensionController().getActionDelegate(extension); + } + + private void actionUpdate( + final Message message, + final WebExtension extension, + final @WebExtension.Action.ActionType int actionType) { + if (extension == null) { + return; + } + + final WebExtension.ActionDelegate delegate = actionDelegateFor(extension, message.session); + if (delegate == null) { + return; + } + + final WebExtension.Action action = + new WebExtension.Action(actionType, message.bundle.getBundle("action"), extension); + if (actionType == WebExtension.Action.TYPE_BROWSER_ACTION) { + delegate.onBrowserAction(extension, message.session, action); + } else if (actionType == WebExtension.Action.TYPE_PAGE_ACTION) { + delegate.onPageAction(extension, message.session, action); + } + } + + // TODO: implement bug 1595822 + /* package */ static GeckoResult<List<WebExtension.Menu>> getMenu( + final GeckoBundle menuArrayBundle) { + return null; + } + + @Nullable + @UiThread + public WebExtension.Download createDownload(final int id) { + if (mDownloads.indexOfKey(id) >= 0) { + throw new IllegalArgumentException("Download with this id already exists"); + } else { + final WebExtension.Download download = new WebExtension.Download(id); + mDownloads.put(id, download); + + return download; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java new file mode 100644 index 0000000000..520cb9faa0 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebMessage.java @@ -0,0 +1,117 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.Map; +import java.util.TreeMap; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** This is an abstract base class for HTTP request and response types. */ +@WrapForJNI +@AnyThread +public abstract class WebMessage { + + /** The URI for the request or response. */ + public final @NonNull String uri; + + /** An unmodifiable Map of headers. Defaults to an empty instance. */ + public final @NonNull Map<String, String> headers; + + protected WebMessage(final @NonNull Builder builder) { + uri = builder.mUri; + headers = Collections.unmodifiableMap(builder.mHeaders); + } + + // This is only used via JNI. + private String[] getHeaderKeys() { + final String[] keys = new String[headers.size()]; + headers.keySet().toArray(keys); + return keys; + } + + // This is only used via JNI. + private String[] getHeaderValues() { + final String[] values = new String[headers.size()]; + headers.values().toArray(values); + return values; + } + + /** This is a Builder used by subclasses of {@link WebMessage}. */ + @AnyThread + public abstract static class Builder { + /* package */ String mUri; + /* package */ Map<String, String> mHeaders = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + /* package */ ByteBuffer mBody; + + /** + * Construct a Builder instance with the specified URI. + * + * @param uri A URI String. + */ + /* package */ Builder(final @NonNull String uri) { + uri(uri); + } + + /** + * Set the URI + * + * @param uri A URI String + * @return This Builder instance. + */ + public @NonNull Builder uri(final @NonNull String uri) { + mUri = uri; + return this; + } + + /** + * Set a HTTP header. This may be called multiple times for additional headers. If an existing + * header of the same name exists, it will be replaced by this value. + * + * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve + * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten + * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order. + * + * @param key The key for the HTTP header, e.g. "content-type". + * @param value The value for the HTTP header, e.g. "application/json". + * @return This Builder instance. + */ + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + mHeaders.put(key, value); + return this; + } + + /** + * Add a HTTP header. This may be called multiple times for additional headers. If an existing + * header of the same name exists, the values will be merged. + * + * <p>Please note that the HTTP header keys are case-insensitive. It means you can retrieve + * "Content-Type" with map.get("content-type"), and value for "Content-Type" will be overwritten + * by map.put("cONTENt-TYpe", value); The keys are also sorted in natural order. + * + * @param key The key for the HTTP header, e.g. "content-type". + * @param value The value for the HTTP header, e.g. "application/json". + * @return This Builder instance. + */ + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + final String existingValue = mHeaders.get(key); + if (existingValue != null) { + final StringBuilder builder = new StringBuilder(existingValue); + builder.append(", "); + builder.append(value); + mHeaders.put(key, builder.toString()); + } else { + mHeaders.put(key, value); + } + + return this; + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java new file mode 100644 index 0000000000..c2de231f80 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotification.java @@ -0,0 +1,233 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import android.os.Parcel; +import android.os.ParcelFormatException; +import android.os.Parcelable; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.GeckoAppShell; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.ThreadUtils; + +/** + * This class represents a single <a + * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification">Web Notification</a>. These + * can be received by connecting a {@link WebNotificationDelegate} to {@link GeckoRuntime} via + * {@link GeckoRuntime#setWebNotificationDelegate(WebNotificationDelegate)}. + */ +public class WebNotification implements Parcelable { + + /** + * Title is shown at the top of the notification window. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/title">Web + * Notification - title</a> + */ + public final @Nullable String title; + + /** + * Tag is the ID of the notification. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/tag">Web + * Notification - tag</a> + */ + public final @NonNull String tag; + + private final @Nullable String mCookie; + + /** + * Text represents the body of the notification. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/body">Web + * Notification - text</a> + */ + public final @Nullable String text; + + /** + * ImageURL contains the URL of an icon to be displayed as part of the notification. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/icon">Web + * Notification - icon</a> + */ + public final @Nullable String imageUrl; + + /** + * TextDirection indicates the direction that the language of the text is displayed. Possible + * values are: auto: adopts the browser's language setting behaviour (the default.) ltr: left to + * right. rtl: right to left. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/dir">Web + * Notification - dir</a> + */ + public final @Nullable String textDirection; + + /** + * Lang indicates the notification's language, as specified using a DOMString representing a BCP + * 47 language tag. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/DOMString">DOM String</a> + * @see <a href="http://www.rfc-editor.org/rfc/bcp/bcp47.txt">BCP 47</a> + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/lang">Web + * Notification - lang</a> + */ + public final @Nullable String lang; + + /** + * RequireInteraction indicates whether a notification should remain active until the user clicks + * or dismisses it, rather than closing automatically. + * + * @see <a + * href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/requireInteraction">Web + * Notification - requireInteraction</a> + */ + public final @NonNull boolean requireInteraction; + + /** + * This is the URL of the page or Service Worker that generated the notification. Null if this + * notification was not generated by a Web Page (e.g. from an Extension). + * + * <p>TODO: make NonNull once we have Bug 1589693 + */ + public final @Nullable String source; + + /** + * When set, indicates that no sounds or vibrations should be made. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent">Web + * Notification - silent</a> + */ + public final boolean silent; + + /** indicates whether the notification came from private browsing mode or not. */ + public final boolean privateBrowsing; + + /** + * A vibration pattern to run with the display of the notification. A vibration pattern can be an + * array with as few as one member. The values are times in milliseconds where the even indices + * (0, 2, 4, etc.) indicate how long to vibrate and the odd indices indicate how long to pause. + * For example, [300, 100, 400] would vibrate 300ms, pause 100ms, then vibrate 400ms. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate">Web + * Notification - vibrate</a> + */ + public final @NonNull int[] vibrate; + + @WrapForJNI + /* package */ WebNotification( + @Nullable final String title, + @NonNull final String tag, + @Nullable final String cookie, + @Nullable final String text, + @Nullable final String imageUrl, + @Nullable final String textDirection, + @Nullable final String lang, + @NonNull final boolean requireInteraction, + @NonNull final String source, + final boolean silent, + final boolean privateBrowsing, + @NonNull final int[] vibrate) { + this.tag = tag; + this.mCookie = cookie; + this.title = title; + this.text = text; + this.imageUrl = imageUrl; + this.textDirection = textDirection; + this.lang = lang; + this.requireInteraction = requireInteraction; + this.source = "".equals(source) ? null : source; + this.silent = silent; + this.vibrate = vibrate; + this.privateBrowsing = privateBrowsing; + } + + /** + * This should be called when the user taps or clicks a notification. Note that this does not + * automatically dismiss the notification as far as Web Content is concerned. For that, see {@link + * #dismiss()}. + */ + @UiThread + public void click() { + ThreadUtils.assertOnUiThread(); + GeckoAppShell.onNotificationClick(tag, mCookie); + } + + /** + * This should be called when the app stops showing the notification. This is important, as there + * may be a limit to the number of active notifications each site can display. + */ + @UiThread + public void dismiss() { + ThreadUtils.assertOnUiThread(); + GeckoAppShell.onNotificationClose(tag, mCookie); + } + + // Increment this value whenever anything changes in the parcelable representation. + private static final int VERSION = 1; + + // To avoid TransactionTooLargeException, we only store small imageUrls + private static final int IMAGE_URL_LENGTH_MAX = 150; + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeInt(VERSION); + dest.writeString(title); + dest.writeString(tag); + dest.writeString(mCookie); + dest.writeString(text); + if (imageUrl.length() < IMAGE_URL_LENGTH_MAX) { + dest.writeString(imageUrl); + } else { + dest.writeString(""); + } + dest.writeString(textDirection); + dest.writeString(lang); + dest.writeInt(requireInteraction ? 1 : 0); + dest.writeString(source); + dest.writeInt(silent ? 1 : 0); + dest.writeInt(privateBrowsing ? 1 : 0); + dest.writeIntArray(vibrate); + } + + private WebNotification(final Parcel in) { + title = in.readString(); + tag = in.readString(); + mCookie = in.readString(); + text = in.readString(); + imageUrl = in.readString(); + textDirection = in.readString(); + lang = in.readString(); + requireInteraction = in.readInt() == 1; + source = in.readString(); + silent = in.readInt() == 1; + privateBrowsing = in.readInt() == 1; + vibrate = in.createIntArray(); + } + + public static final Creator<WebNotification> CREATOR = + new Creator<>() { + @Override + public WebNotification createFromParcel(final Parcel in) { + final int version = in.readInt(); + if (version != VERSION) { + throw new ParcelFormatException( + "Mismatched version: " + version + " expected: " + VERSION); + } + return new WebNotification(in); + } + + @Override + public WebNotification[] newArray(final int size) { + return new WebNotification[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java new file mode 100644 index 0000000000..40db55fa3c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebNotificationDelegate.java @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package org.mozilla.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import org.mozilla.gecko.annotation.WrapForJNI; + +public interface WebNotificationDelegate { + /** + * This is called when a new notification is created. + * + * @param notification The WebNotification received. + */ + @AnyThread + @WrapForJNI + default void onShowNotification(@NonNull final WebNotification notification) {} + + /** + * This is called when an existing notification is closed. + * + * @param notification The WebNotification received. + */ + @AnyThread + @WrapForJNI + default void onCloseNotification(@NonNull final WebNotification notification) {} +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java new file mode 100644 index 0000000000..f5ea153bfe --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushController.java @@ -0,0 +1,165 @@ +/* -*- 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.geckoview; + +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import org.mozilla.gecko.EventDispatcher; +import org.mozilla.gecko.GeckoThread; +import org.mozilla.gecko.util.BundleEventListener; +import org.mozilla.gecko.util.EventCallback; +import org.mozilla.gecko.util.GeckoBundle; +import org.mozilla.gecko.util.ThreadUtils; + +public class WebPushController { + private static final String LOGTAG = "WebPushController"; + + private WebPushDelegate mDelegate; + private BundleEventListener mEventListener; + + /* package */ WebPushController() { + mEventListener = new EventListener(); + EventDispatcher.getInstance() + .registerUiThreadListener( + mEventListener, + "GeckoView:PushSubscribe", + "GeckoView:PushUnsubscribe", + "GeckoView:PushGetSubscription"); + } + + /** + * Sets the {@link WebPushDelegate} for this instance. + * + * @param delegate The {@link WebPushDelegate} instance. + */ + @UiThread + public void setDelegate(final @Nullable WebPushDelegate delegate) { + ThreadUtils.assertOnUiThread(); + mDelegate = delegate; + } + + /** + * Gets the {@link WebPushDelegate} for this instance. + * + * @return delegate The {@link WebPushDelegate} instance. + */ + @UiThread + @Nullable + public WebPushDelegate getDelegate() { + ThreadUtils.assertOnUiThread(); + return mDelegate; + } + + /** + * Send a push event for a given subscription. + * + * @param scope The Service Worker scope associated with this subscription. + */ + @UiThread + public void onPushEvent(final @NonNull String scope) { + ThreadUtils.assertOnUiThread(); + onPushEvent(scope, null); + } + + /** + * Send a push event with a payload for a given subscription. + * + * @param scope The Service Worker scope associated with this subscription. + * @param data The unencrypted payload. + */ + @UiThread + public void onPushEvent(final @NonNull String scope, final @Nullable byte[] data) { + ThreadUtils.assertOnUiThread(); + + GeckoThread.waitForState(GeckoThread.State.JNI_READY) + .accept( + val -> { + final GeckoBundle msg = new GeckoBundle(2); + msg.putString("scope", scope); + msg.putString("data", Base64Utils.encode(data)); + EventDispatcher.getInstance().dispatch("GeckoView:PushEvent", msg); + }, + e -> Log.e(LOGTAG, "Unable to deliver Web Push message", e)); + } + + /** + * Notify that a given subscription has changed. This is normally a signal to the content that it + * needs to re-subscribe. + * + * @param scope The Service Worker scope associated with this subscription. + */ + @UiThread + public void onSubscriptionChanged(final @NonNull String scope) { + ThreadUtils.assertOnUiThread(); + + final GeckoBundle msg = new GeckoBundle(1); + msg.putString("scope", scope); + EventDispatcher.getInstance().dispatch("GeckoView:PushSubscriptionChanged", msg); + } + + private class EventListener implements BundleEventListener { + + @Override + public void handleMessage( + final String event, final GeckoBundle message, final EventCallback callback) { + if (mDelegate == null) { + callback.sendError("Not allowed"); + return; + } + + switch (event) { + case "GeckoView:PushSubscribe": + { + byte[] appServerKey = null; + if (message.containsKey("appServerKey")) { + appServerKey = Base64Utils.decode(message.getString("appServerKey")); + } + + final GeckoResult<WebPushSubscription> result = + mDelegate.onSubscribe(message.getString("scope"), appServerKey); + + if (result == null) { + callback.sendSuccess(null); + return; + } + + result.accept( + subscription -> + callback.sendSuccess(subscription != null ? subscription.toBundle() : null), + error -> callback.sendSuccess(null)); + break; + } + case "GeckoView:PushUnsubscribe": + { + final GeckoResult<Void> result = mDelegate.onUnsubscribe(message.getString("scope")); + if (result == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo(result.map(val -> null)); + break; + } + case "GeckoView:PushGetSubscription": + { + final GeckoResult<WebPushSubscription> result = + mDelegate.onGetSubscription(message.getString("scope")); + if (result == null) { + callback.sendSuccess(null); + return; + } + + callback.resolveTo( + result.map(subscription -> subscription != null ? subscription.toBundle() : null)); + break; + } + } + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java new file mode 100644 index 0000000000..d9e9c39274 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushDelegate.java @@ -0,0 +1,62 @@ +/* -*- 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.geckoview; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +public interface WebPushDelegate { + /** + * Creates a push subscription for the given service worker scope. A scope uniquely identifies a + * service worker. `appServerKey` optionally creates a restricted subscription. + * + * <p>Applications will likely want to persist the returned {@link WebPushSubscription} in order + * to support {@link #onGetSubscription(String)}. + * + * @param scope The Service Worker scope. + * @param appServerKey An optional application server key. + * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription} + * @see <a href="http://w3c.github.io/push-api/#dom-pushmanager-subscribe">subscribe()</a> + * @see <a + * href="http://w3c.github.io/push-api/#dom-pushsubscriptionoptionsinit-applicationserverkey">Application + * server key</a> + */ + @UiThread + default @Nullable GeckoResult<WebPushSubscription> onSubscribe( + @NonNull final String scope, @Nullable final byte[] appServerKey) { + return null; + } + + /** + * Retrieves a subscription for the given service worker scope. + * + * @param scope The scope for the requested {@link WebPushSubscription}. + * @return A {@link GeckoResult} which resolves to a {@link WebPushSubscription} + * @see <a + * href="http://w3c.github.io/push-api/#dom-pushmanager-getsubscription">getSubscription()</a> + */ + @UiThread + default @Nullable GeckoResult<WebPushSubscription> onGetSubscription( + @NonNull final String scope) { + return null; + } + + /** + * Removes a push subscription. If this fails, apps should resolve the returned {@link + * GeckoResult} with an exception. + * + * @param scope The Service Worker scope for the subscription. + * @return A {@link GeckoResult}, which if non-exceptional indicates successfully unsubscribing. + * @see <a + * href="http://w3c.github.io/push-api/#dom-pushsubscription-unsubscribe">unsubscribe()</a> + */ + @UiThread + default @Nullable GeckoResult<Void> onUnsubscribe(@NonNull final String scope) { + return null; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java new file mode 100644 index 0000000000..7ce9a3d60c --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebPushSubscription.java @@ -0,0 +1,180 @@ +/* -*- 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.geckoview; + +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.Arrays; +import org.mozilla.gecko.util.GeckoBundle; + +/** + * This class represents a single Web Push subscription, as described in the <a + * href="https://www.w3.org/TR/push-api/">Web Push API</a> specification. + * + * <p>This is a low-level interface, allowing applications to do all of the heavy lifting + * themselves. It is recommended that consumers have a thorough understanding of the Web Push API, + * especially <a href="https://tools.ietf.org/html/rfc8291">RFC 8291</a>. + * + * <p>Only trivial sanity checks are performed on the values held here. The application must ensure + * it is generating compliant keys/secrets itself. + */ +public class WebPushSubscription implements Parcelable { + private static final int P256_PUBLIC_KEY_LENGTH = 65; + + /** + * The Service Worker scope associated with this subscription. + * + * @see <a + * href="https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register">ServiceWorker + * registration</a> + */ + @NonNull public final String scope; + + /** + * The Web Push endpoint for this subscription. This is the URL of a web service which implements + * the Web Push protocol. + * + * @see <a href="https://tools.ietf.org/html/rfc8030#section-5">RFC 8030</a> + */ + @NonNull public final String endpoint; + + /** + * This is an optional public key provided by the application server to authenticate itself with + * the endpoint, formatted according to X9.62. + * + * <p>This key is used for VAPID, the Voluntary Application Server Identification (VAPID) for Web + * Push, from <a href="https://tools.ietf.org/html/rfc8292">RFC 8292</a>. + * + * @see <a + * href="https://www.w3.org/TR/push-api/#dom-pushsubscriptionoptions-applicationserverkey">applicationServerKey</a> + * @see <a href="https://tools.ietf.org/html/rfc8291">Message Encryption for Web Push</a> + */ + @Nullable public final byte[] appServerKey; + + /** + * The P-256 EC public key, formatted as X9.62, generated by the embedder, to be provided to the + * app server for message encryption. + * + * @see <a + * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-p256dh">PushEncryptionKeyName + * - p256dh</a> + * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.1">RFC 8291 section 3.1</a> + */ + @NonNull public final byte[] browserPublicKey; + + /** + * 16 byte secret key, generated by the embedder, to be provided to the app server for use in + * encrypting and authenticating messages sent to the {@link #endpoint}. + * + * @see <a + * href="https://www.w3.org/TR/push-api/#dom-pushencryptionkeyname-auth">PushEncryptionKeyName + * - auth</a> + * @see <a href="https://tools.ietf.org/html/rfc8291#section-3.2">RFC 8291, section 3.2</a> + */ + @NonNull public final byte[] authSecret; + + @SuppressWarnings("checkstyle:javadocmethod") + public WebPushSubscription( + final @NonNull String scope, + final @NonNull String endpoint, + final @Nullable byte[] appServerKey, + final @NonNull byte[] browserPublicKey, + final @NonNull byte[] authSecret) { + this.scope = scope; + this.endpoint = endpoint; + this.appServerKey = appServerKey; + this.browserPublicKey = browserPublicKey; + this.authSecret = authSecret; + + if (appServerKey != null) { + if (appServerKey.length != P256_PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + String.format("appServerKey should be %d bytes", P256_PUBLIC_KEY_LENGTH)); + } + + if (Arrays.equals(appServerKey, browserPublicKey)) { + throw new IllegalArgumentException("appServerKey and browserPublicKey must differ"); + } + } + + if (browserPublicKey.length != P256_PUBLIC_KEY_LENGTH) { + throw new IllegalArgumentException( + String.format("browserPublicKey should be %d bytes", P256_PUBLIC_KEY_LENGTH)); + } + + if (authSecret.length != 16) { + throw new IllegalArgumentException("authSecret must be 128 bits"); + } + } + + private WebPushSubscription(final Parcel in) { + this.scope = in.readString(); + this.endpoint = in.readString(); + + if (ParcelableUtils.readBoolean(in)) { + this.appServerKey = new byte[P256_PUBLIC_KEY_LENGTH]; + in.readByteArray(this.appServerKey); + } else { + appServerKey = null; + } + + this.browserPublicKey = new byte[P256_PUBLIC_KEY_LENGTH]; + in.readByteArray(this.browserPublicKey); + + this.authSecret = new byte[16]; + in.readByteArray(this.authSecret); + } + + /* package */ GeckoBundle toBundle() { + final GeckoBundle bundle = new GeckoBundle(5); + bundle.putString("scope", scope); + bundle.putString("endpoint", endpoint); + if (appServerKey != null) { + bundle.putString("appServerKey", Base64Utils.encode(appServerKey)); + } + bundle.putString("browserPublicKey", Base64Utils.encode(browserPublicKey)); + bundle.putString("authSecret", Base64Utils.encode(authSecret)); + return bundle; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + out.writeString(scope); + out.writeString(endpoint); + + ParcelableUtils.writeBoolean(out, appServerKey != null); + if (appServerKey != null) { + out.writeByteArray(appServerKey); + } + + out.writeByteArray(browserPublicKey); + out.writeByteArray(authSecret); + } + + public static final Parcelable.Creator<WebPushSubscription> CREATOR = + new Parcelable.Creator<WebPushSubscription>() { + @Override + @AnyThread + public WebPushSubscription createFromParcel(final Parcel parcel) { + return new WebPushSubscription(parcel); + } + + @Override + @AnyThread + public WebPushSubscription[] newArray(final int size) { + return new WebPushSubscription[size]; + } + }; +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java new file mode 100644 index 0000000000..30ee5451aa --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequest.java @@ -0,0 +1,248 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * WebRequest represents an HTTP[S] request. The typical pattern is to create instances of this + * class via {@link WebRequest.Builder}, and fetch responses via {@link + * GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +@AnyThread +public class WebRequest extends WebMessage { + /** The HTTP method for the request. Defaults to "GET". */ + public final @NonNull String method; + + /** The body of the request. Must be a directly-allocated ByteBuffer. May be null. */ + public final @Nullable ByteBuffer body; + + /** + * The cache mode for the request. See {@link #CACHE_MODE_DEFAULT}. These modes match those from + * the DOM Fetch API. + * + * @see <a href="https://developer.mozilla.org/en-US/docs/Web/API/Request/cache">DOM Fetch API + * cache modes</a> + */ + public final @CacheMode int cacheMode; + + /** + * If true, do not use newer protocol features that might have interop problems on the Internet. + * Intended only for use with critical infrastructure. + */ + public final boolean beConservative; + + /** The value of the Referer header for this request. */ + public final @Nullable String referrer; + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + CACHE_MODE_DEFAULT, + CACHE_MODE_NO_STORE, + CACHE_MODE_RELOAD, + CACHE_MODE_NO_CACHE, + CACHE_MODE_FORCE_CACHE, + CACHE_MODE_ONLY_IF_CACHED + }) + public @interface CacheMode {}; + + /** Default cache mode. Normal caching rules apply. */ + public static final int CACHE_MODE_DEFAULT = 1; + + /** + * The response will be fetched from the server without looking in the cache, and will not update + * the cache with the downloaded response. + */ + public static final int CACHE_MODE_NO_STORE = 2; + + /** + * The response will be fetched from the server without looking in the cache. The cache will be + * updated with the downloaded response. + */ + public static final int CACHE_MODE_RELOAD = 3; + + /** Forces a conditional request to the server if there is a cache match. */ + public static final int CACHE_MODE_NO_CACHE = 4; + + /** + * If a response is found in the cache, it will be returned, whether it's fresh or not. If there + * is no match, a normal request will be made and the cache will be updated with the downloaded + * response. + */ + public static final int CACHE_MODE_FORCE_CACHE = 5; + + /** + * If a response is found in the cache, it will be returned, whether it's fresh or not. If there + * is no match from the cache, 504 Gateway Timeout will be returned. + */ + public static final int CACHE_MODE_ONLY_IF_CACHED = 6; + + /* package */ static final int CACHE_MODE_FIRST = CACHE_MODE_DEFAULT; + /* package */ static final int CACHE_MODE_LAST = CACHE_MODE_ONLY_IF_CACHED; + + /** + * Constructs a WebRequest with the specified URI. + * + * @param uri A URI String, e.g. https://mozilla.org + */ + public WebRequest(final @NonNull String uri) { + this(new Builder(uri)); + } + + /** Constructs a new WebRequest from a {@link WebRequest.Builder}. */ + /* package */ WebRequest(final @NonNull Builder builder) { + super(builder); + method = builder.mMethod; + cacheMode = builder.mCacheMode; + referrer = builder.mReferrer; + beConservative = builder.mBeConservative; + + if (builder.mBody != null) { + body = builder.mBody.asReadOnlyBuffer(); + } else { + body = null; + } + } + + /** Builder offers a convenient way for constructing {@link WebRequest} instances. */ + @AnyThread + public static class Builder extends WebMessage.Builder { + /* package */ String mMethod = "GET"; + /* package */ int mCacheMode = CACHE_MODE_DEFAULT; + /* package */ String mReferrer; + /* package */ boolean mBeConservative; + + /** + * Construct a Builder instance with the specified URI. + * + * @param uri A URI String. + */ + public Builder(final @NonNull String uri) { + super(uri); + } + + @Override + public @NonNull Builder uri(final @NonNull String uri) { + super.uri(uri); + return this; + } + + @Override + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + super.header(key, value); + return this; + } + + @Override + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + super.addHeader(key, value); + return this; + } + + /** + * Set the body. + * + * @param buffer A {@link ByteBuffer} with the data. Must be allocated directly via {@link + * ByteBuffer#allocateDirect(int)}. + * @return This Builder instance. + */ + public @NonNull Builder body(final @Nullable ByteBuffer buffer) { + if (buffer != null && !buffer.isDirect()) { + throw new IllegalArgumentException("body must be directly allocated"); + } + mBody = buffer; + return this; + } + + /** + * Set the body. + * + * @param bodyString A {@link String} with the data. + * @return This Builder instance. + */ + public @NonNull Builder body(final @Nullable String bodyString) { + if (bodyString == null) { + mBody = null; + return this; + } + final CharBuffer chars = CharBuffer.wrap(bodyString); + final ByteBuffer buffer = ByteBuffer.allocateDirect(bodyString.length()); + Charset.forName("UTF-8").newEncoder().encode(chars, buffer, true); + + mBody = buffer; + return this; + } + + /** + * Set the HTTP method. + * + * @param method The HTTP method String. + * @return This Builder instance. + */ + public @NonNull Builder method(final @NonNull String method) { + mMethod = method; + return this; + } + + /** + * Set the cache mode. + * + * @param mode One of the {@link #CACHE_MODE_DEFAULT CACHE_*} flags. + * @return This Builder instance. + */ + public @NonNull Builder cacheMode(final @CacheMode int mode) { + if (mode < CACHE_MODE_FIRST || mode > CACHE_MODE_LAST) { + throw new IllegalArgumentException("Unknown cache mode"); + } + mCacheMode = mode; + return this; + } + + /** + * Set the HTTP Referer header. + * + * @param referrer A URI String + * @return This Builder instance. + */ + public @NonNull Builder referrer(final @Nullable String referrer) { + mReferrer = referrer; + return this; + } + + /** + * Set the beConservative property. + * + * @param beConservative If true, do not use newer protocol features that might have interop + * problems on the Internet. Intended only for use with critical infrastructure. + * @return This Builder instance. + */ + public @NonNull Builder beConservative(final boolean beConservative) { + mBeConservative = beConservative; + return this; + } + + /** + * @return A {@link WebRequest} constructed with the values from this Builder instance. + */ + public @NonNull WebRequest build() { + if (mUri == null) { + throw new IllegalStateException("Must set URI"); + } + return new WebRequest(this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java new file mode 100644 index 0000000000..455078feb7 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebRequestError.java @@ -0,0 +1,380 @@ +/* -*- 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.geckoview; + +import android.annotation.SuppressLint; +import androidx.annotation.AnyThread; +import androidx.annotation.IntDef; +import androidx.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import org.mozilla.gecko.annotation.WrapForJNI; +import org.mozilla.gecko.util.XPCOMError; + +/** + * WebRequestError is simply a container for error codes and categories used by {@link + * GeckoSession.NavigationDelegate#onLoadError(GeckoSession, String, WebRequestError)}. + */ +@AnyThread +public class WebRequestError extends Exception { + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ERROR_CATEGORY_UNKNOWN, + ERROR_CATEGORY_SECURITY, + ERROR_CATEGORY_NETWORK, + ERROR_CATEGORY_CONTENT, + ERROR_CATEGORY_URI, + ERROR_CATEGORY_PROXY, + ERROR_CATEGORY_SAFEBROWSING + }) + public @interface ErrorCategory {} + + @Retention(RetentionPolicy.SOURCE) + @IntDef({ + ERROR_UNKNOWN, + ERROR_SECURITY_SSL, + ERROR_SECURITY_BAD_CERT, + ERROR_NET_RESET, + ERROR_NET_INTERRUPT, + ERROR_NET_TIMEOUT, + ERROR_CONNECTION_REFUSED, + ERROR_UNKNOWN_PROTOCOL, + ERROR_UNKNOWN_HOST, + ERROR_UNKNOWN_SOCKET_TYPE, + ERROR_UNKNOWN_PROXY_HOST, + ERROR_MALFORMED_URI, + ERROR_REDIRECT_LOOP, + ERROR_SAFEBROWSING_PHISHING_URI, + ERROR_SAFEBROWSING_MALWARE_URI, + ERROR_SAFEBROWSING_UNWANTED_URI, + ERROR_SAFEBROWSING_HARMFUL_URI, + ERROR_CONTENT_CRASHED, + ERROR_OFFLINE, + ERROR_PORT_BLOCKED, + ERROR_PROXY_CONNECTION_REFUSED, + ERROR_FILE_NOT_FOUND, + ERROR_FILE_ACCESS_DENIED, + ERROR_INVALID_CONTENT_ENCODING, + ERROR_UNSAFE_CONTENT_TYPE, + ERROR_CORRUPTED_CONTENT, + ERROR_DATA_URI_TOO_LONG, + ERROR_HTTPS_ONLY, + ERROR_BAD_HSTS_CERT + }) + public @interface Error {} + + /** + * This is normally used for error codes that don't currently fit into any of the other + * categories. + */ + public static final int ERROR_CATEGORY_UNKNOWN = 0x1; + + /** This is used for error codes that relate to SSL certificate validation. */ + public static final int ERROR_CATEGORY_SECURITY = 0x2; + + /** This is used for error codes relating to network problems. */ + public static final int ERROR_CATEGORY_NETWORK = 0x3; + + /** This is used for error codes relating to invalid or corrupt web pages. */ + public static final int ERROR_CATEGORY_CONTENT = 0x4; + + public static final int ERROR_CATEGORY_URI = 0x5; + public static final int ERROR_CATEGORY_PROXY = 0x6; + public static final int ERROR_CATEGORY_SAFEBROWSING = 0x7; + + /** An unknown error occurred */ + public static final int ERROR_UNKNOWN = 0x11; + + // Security + /** This is used for a variety of SSL negotiation problems. */ + public static final int ERROR_SECURITY_SSL = 0x22; + + /** This is used to indicate an untrusted or otherwise invalid SSL certificate. */ + public static final int ERROR_SECURITY_BAD_CERT = 0x32; + + // Network + /** The network connection was interrupted. */ + public static final int ERROR_NET_INTERRUPT = 0x23; + + /** The network request timed out. */ + public static final int ERROR_NET_TIMEOUT = 0x33; + + /** The network request was refused by the server. */ + public static final int ERROR_CONNECTION_REFUSED = 0x43; + + /** The network request tried to use an unknown socket type. */ + public static final int ERROR_UNKNOWN_SOCKET_TYPE = 0x53; + + /** A redirect loop was detected. */ + public static final int ERROR_REDIRECT_LOOP = 0x63; + + /** This device does not have a network connection. */ + public static final int ERROR_OFFLINE = 0x73; + + /** The request tried to use a port that is blocked by either the OS or Gecko. */ + public static final int ERROR_PORT_BLOCKED = 0x83; + + /** The connection was reset. */ + public static final int ERROR_NET_RESET = 0x93; + + /** + * GeckoView could not connect to this website in HTTPS-only mode. Call + * document.reloadWithHttpsOnlyException() in the error page to temporarily disable HTTPS only + * mode for this request. + * + * <p>See also {@link GeckoSession.NavigationDelegate#onLoadError} + */ + public static final int ERROR_HTTPS_ONLY = 0xA3; + + /** + * A certificate validation error occurred when connecting to a site that does not allow error + * overrides. + */ + public static final int ERROR_BAD_HSTS_CERT = 0xB3; + + // Content + /** A content type was returned which was deemed unsafe. */ + public static final int ERROR_UNSAFE_CONTENT_TYPE = 0x24; + + /** The content returned was corrupted. */ + public static final int ERROR_CORRUPTED_CONTENT = 0x34; + + /** The content process crashed. */ + public static final int ERROR_CONTENT_CRASHED = 0x44; + + /** The content has an invalid encoding. */ + public static final int ERROR_INVALID_CONTENT_ENCODING = 0x54; + + // URI + /** The host could not be resolved. */ + public static final int ERROR_UNKNOWN_HOST = 0x25; + + /** An invalid URL was specified. */ + public static final int ERROR_MALFORMED_URI = 0x35; + + /** An unknown protocol was specified. */ + public static final int ERROR_UNKNOWN_PROTOCOL = 0x45; + + /** A file was not found (usually used for file:// URIs). */ + public static final int ERROR_FILE_NOT_FOUND = 0x55; + + /** The OS blocked access to a file. */ + public static final int ERROR_FILE_ACCESS_DENIED = 0x65; + + /** A data:// URI is too long to load at the top level. */ + public static final int ERROR_DATA_URI_TOO_LONG = 0x75; + + // Proxy + /** The proxy server refused the connection. */ + public static final int ERROR_PROXY_CONNECTION_REFUSED = 0x26; + + /** The host name of the proxy server could not be resolved. */ + public static final int ERROR_UNKNOWN_PROXY_HOST = 0x36; + + // Safebrowsing + /** The requested URI was present in the "malware" blocklist. */ + public static final int ERROR_SAFEBROWSING_MALWARE_URI = 0x27; + + /** The requested URI was present in the "unwanted" blocklist. */ + public static final int ERROR_SAFEBROWSING_UNWANTED_URI = 0x37; + + /** The requested URI was present in the "harmful" blocklist. */ + public static final int ERROR_SAFEBROWSING_HARMFUL_URI = 0x47; + + /** The requested URI was present in the "phishing" blocklist. */ + public static final int ERROR_SAFEBROWSING_PHISHING_URI = 0x57; + + /** The error code, e.g. {@link #ERROR_MALFORMED_URI}. */ + public final int code; + + /** The error category, e.g. {@link #ERROR_CATEGORY_URI}. */ + public final int category; + + /** + * The server certificate used. This can be useful if the error code is is e.g. {@link + * #ERROR_SECURITY_BAD_CERT}. + */ + public final @Nullable X509Certificate certificate; + + /** + * Construct a new WebRequestError with the specified code and category. + * + * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI} + * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI} + */ + public WebRequestError(final @Error int code, final @ErrorCategory int category) { + this(code, category, null); + } + + /** + * Construct a new WebRequestError with the specified code and category. + * + * @param code An error code, e.g. {@link #ERROR_MALFORMED_URI} + * @param category An error category, e.g. {@link #ERROR_CATEGORY_URI} + * @param certificate The X509Certificate server certificate used, if applicable. + */ + public WebRequestError( + final @Error int code, final @ErrorCategory int category, final X509Certificate certificate) { + super(String.format("Request failed, error=0x%x, category=0x%x", code, category)); + this.code = code; + this.category = category; + this.certificate = certificate; + } + + @Override + public boolean equals(final Object other) { + if (other == null || !(other instanceof WebRequestError)) { + return false; + } + + final WebRequestError otherError = (WebRequestError) other; + + // We don't compare the certificate here because it's almost never what you want. + return otherError.code == this.code && otherError.category == this.category; + } + + @Override + public int hashCode() { + return Arrays.hashCode(new Object[] {category, code}); + } + + @WrapForJNI + /* package */ static WebRequestError fromGeckoError( + final long geckoError, + final int geckoErrorModule, + final int geckoErrorClass, + final byte[] certificateBytes) { + // XXX: the geckoErrorModule argument is redundant + assert geckoErrorModule == XPCOMError.getErrorModule(geckoError); + final int code = convertGeckoError(geckoError, geckoErrorClass); + final int category = getErrorCategory(XPCOMError.getErrorModule(geckoError), code); + X509Certificate certificate = null; + if (certificateBytes != null) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + certificate = + (X509Certificate) + factory.generateCertificate(new ByteArrayInputStream(certificateBytes)); + } catch (final CertificateException e) { + throw new IllegalArgumentException("Unable to parse DER certificate"); + } + } + + return new WebRequestError(code, category, certificate); + } + + @SuppressLint("WrongConstant") + @WrapForJNI + /* package */ static @ErrorCategory int getErrorCategory( + final long errorModule, final @Error int error) { + if (errorModule == XPCOMError.NS_ERROR_MODULE_SECURITY) { + return ERROR_CATEGORY_SECURITY; + } + return error & 0xF; + } + + @WrapForJNI + /* package */ static @Error int convertGeckoError( + final long geckoError, final int geckoErrorClass) { + // safebrowsing + if (geckoError == XPCOMError.NS_ERROR_PHISHING_URI) { + return ERROR_SAFEBROWSING_PHISHING_URI; + } + if (geckoError == XPCOMError.NS_ERROR_MALWARE_URI) { + return ERROR_SAFEBROWSING_MALWARE_URI; + } + if (geckoError == XPCOMError.NS_ERROR_UNWANTED_URI) { + return ERROR_SAFEBROWSING_UNWANTED_URI; + } + if (geckoError == XPCOMError.NS_ERROR_HARMFUL_URI) { + return ERROR_SAFEBROWSING_HARMFUL_URI; + } + // content + if (geckoError == XPCOMError.NS_ERROR_CONTENT_CRASHED) { + return ERROR_CONTENT_CRASHED; + } + if (geckoError == XPCOMError.NS_ERROR_INVALID_CONTENT_ENCODING) { + return ERROR_INVALID_CONTENT_ENCODING; + } + if (geckoError == XPCOMError.NS_ERROR_UNSAFE_CONTENT_TYPE) { + return ERROR_UNSAFE_CONTENT_TYPE; + } + if (geckoError == XPCOMError.NS_ERROR_CORRUPTED_CONTENT) { + return ERROR_CORRUPTED_CONTENT; + } + // network + if (geckoError == XPCOMError.NS_ERROR_NET_RESET) { + return ERROR_NET_RESET; + } + if (geckoError == XPCOMError.NS_ERROR_NET_RESET) { + return ERROR_NET_INTERRUPT; + } + if (geckoError == XPCOMError.NS_ERROR_NET_TIMEOUT) { + return ERROR_NET_TIMEOUT; + } + if (geckoError == XPCOMError.NS_ERROR_CONNECTION_REFUSED) { + return ERROR_CONNECTION_REFUSED; + } + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_SOCKET_TYPE) { + return ERROR_UNKNOWN_SOCKET_TYPE; + } + if (geckoError == XPCOMError.NS_ERROR_REDIRECT_LOOP) { + return ERROR_REDIRECT_LOOP; + } + if (geckoError == XPCOMError.NS_ERROR_HTTPS_ONLY) { + return ERROR_HTTPS_ONLY; + } + if (geckoError == XPCOMError.NS_ERROR_BAD_HSTS_CERT) { + return ERROR_BAD_HSTS_CERT; + } + if (geckoError == XPCOMError.NS_ERROR_OFFLINE) { + return ERROR_OFFLINE; + } + if (geckoError == XPCOMError.NS_ERROR_PORT_ACCESS_NOT_ALLOWED) { + return ERROR_PORT_BLOCKED; + } + // uri + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROTOCOL) { + return ERROR_UNKNOWN_PROTOCOL; + } + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_HOST) { + return ERROR_UNKNOWN_HOST; + } + if (geckoError == XPCOMError.NS_ERROR_MALFORMED_URI) { + return ERROR_MALFORMED_URI; + } + if (geckoError == XPCOMError.NS_ERROR_FILE_NOT_FOUND) { + return ERROR_FILE_NOT_FOUND; + } + if (geckoError == XPCOMError.NS_ERROR_FILE_ACCESS_DENIED) { + return ERROR_FILE_ACCESS_DENIED; + } + // proxy + if (geckoError == XPCOMError.NS_ERROR_UNKNOWN_PROXY_HOST) { + return ERROR_UNKNOWN_PROXY_HOST; + } + if (geckoError == XPCOMError.NS_ERROR_PROXY_CONNECTION_REFUSED) { + return ERROR_PROXY_CONNECTION_REFUSED; + } + + if (XPCOMError.getErrorModule(geckoError) == XPCOMError.NS_ERROR_MODULE_SECURITY) { + if (geckoErrorClass == 1) { + return ERROR_SECURITY_SSL; + } + if (geckoErrorClass == 2) { + return ERROR_SECURITY_BAD_CERT; + } + } + + return ERROR_UNKNOWN; + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java new file mode 100644 index 0000000000..09bdc2a21e --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/WebResponse.java @@ -0,0 +1,183 @@ +/* -*- 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.geckoview; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import org.mozilla.gecko.annotation.WrapForJNI; + +/** + * WebResponse represents an HTTP[S] response. It is normally created by {@link + * GeckoWebExecutor#fetch(WebRequest)}. + */ +@WrapForJNI +@AnyThread +public class WebResponse extends WebMessage { + /** The default read timeout for the {@link #body} stream. */ + public static final long DEFAULT_READ_TIMEOUT_MS = 30000; + + /** The HTTP status code for the response, e.g. 200. */ + public final int statusCode; + + /** A boolean indicating whether or not this response is the result of a redirection. */ + public final boolean redirected; + + /** Whether or not this response was delivered via a secure connection. */ + public final boolean isSecure; + + /** The server certificate used with this response, if any. */ + public final @Nullable X509Certificate certificate; + + /** + * An {@link InputStream} containing the response body, if available. Attention: the stream must + * be closed whenever the app is done with it, even when the body is ignored. Otherwise the + * connection will not be closed until the stream is garbage collected + */ + public final @Nullable InputStream body; + + protected WebResponse(final @NonNull Builder builder) { + super(builder); + this.statusCode = builder.mStatusCode; + this.redirected = builder.mRedirected; + this.body = builder.mBody; + this.isSecure = builder.mIsSecure; + this.certificate = builder.mCertificate; + + this.setReadTimeoutMillis(DEFAULT_READ_TIMEOUT_MS); + } + + /** + * Sets the maximum amount of time to wait for data in the {@link #body} read() method. By + * default, the read timeout is set to {@link #DEFAULT_READ_TIMEOUT_MS}. + * + * <p>If 0, there will be no timeout and read() will block indefinitely. + * + * @param millis The duration in milliseconds for the timeout. + */ + public void setReadTimeoutMillis(final long millis) { + if (this.body != null && this.body instanceof GeckoInputStream) { + ((GeckoInputStream) this.body).setReadTimeoutMillis(millis); + } + } + + /** Builder offers a convenient way to create WebResponse instances. */ + @WrapForJNI + @AnyThread + public static class Builder extends WebMessage.Builder { + /* package */ int mStatusCode; + /* package */ boolean mRedirected; + /* package */ InputStream mBody; + /* package */ boolean mIsSecure; + /* package */ X509Certificate mCertificate; + + /** + * Constructs a new Builder instance with the specified URI. + * + * @param uri A URI String. + */ + public Builder(final @NonNull String uri) { + super(uri); + } + + @Override + public @NonNull Builder uri(final @NonNull String uri) { + super.uri(uri); + return this; + } + + @Override + public @NonNull Builder header(final @NonNull String key, final @NonNull String value) { + super.header(key, value); + return this; + } + + @Override + public @NonNull Builder addHeader(final @NonNull String key, final @NonNull String value) { + super.addHeader(key, value); + return this; + } + + /** + * Sets the {@link InputStream} containing the body of this response. + * + * @param stream An {@link InputStream} with the body of the response. + * @return This Builder instance. + */ + public @NonNull Builder body(final @NonNull InputStream stream) { + mBody = stream; + return this; + } + + /** + * @param isSecure Whether or not this response is secure. + * @return This Builder instance. + */ + public @NonNull Builder isSecure(final boolean isSecure) { + mIsSecure = isSecure; + return this; + } + + /** + * @param certificate The certificate used. + * @return This Builder instance. + */ + public @NonNull Builder certificate(final @NonNull X509Certificate certificate) { + mCertificate = certificate; + return this; + } + + /** + * @param encodedCert The certificate used, encoded via DER. Only used via JNI. + */ + @WrapForJNI(exceptionMode = "nsresult") + private void certificateBytes(final @NonNull byte[] encodedCert) { + try { + final CertificateFactory factory = CertificateFactory.getInstance("X.509"); + final X509Certificate cert = + (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(encodedCert)); + certificate(cert); + } catch (final CertificateException e) { + throw new IllegalArgumentException("Unable to parse DER certificate"); + } + } + + /** + * Set the HTTP status code, e.g. 200. + * + * @param code A int representing the HTTP status code. + * @return This Builder instance. + */ + public @NonNull Builder statusCode(final int code) { + mStatusCode = code; + return this; + } + + /** + * Set whether or not this response was the result of a redirect. + * + * @param redirected A boolean representing whether or not the request was redirected. + * @return This Builder instance. + */ + public @NonNull Builder redirected(final boolean redirected) { + mRedirected = redirected; + return this; + } + + /** + * @return A {@link WebResponse} constructed with the values from this Builder instance. + */ + public @NonNull WebResponse build() { + return new WebResponse(this); + } + } +} diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md new file mode 100644 index 0000000000..55ea58fe19 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/doc-files/CHANGELOG.md @@ -0,0 +1,1299 @@ +--- +layout: default +title: API Changelog +description: GeckoView API Changelog. +nav_exclude: true +exclude: true +--- + +{% capture javadoc_uri %}{{ site.url }}{{ site.baseurl}}/javadoc/mozilla-central/org/mozilla/geckoview{% endcapture %} +{% capture bugzilla %}https://bugzilla.mozilla.org/show_bug.cgi?id={% endcapture %} + +# GeckoView API Changelog. + +⚠️ breaking change and deprecation notices + +## v110 +- Added [`GeckoSession.ContentDelegate.onCookieBannerDetected`][110.1] and [`GeckoSession.ContentDelegate.onCookieBannerHandled`][110.2] +- Added [`CookieBannerMode.COOKIE_BANNER_MODE_DETECT_ONLY`][110.3], for detecting cookie banners but not handle them, see ([bug 1797581]({{bugzilla}}1806188)) +- Added [`StorageController.setCookieBannerModeAndPersistInPrivateBrowsingForDomain`][110.4] see ([bug 1804747]({{bugzilla}}1804747)) +- Added [`Autofill.Node.getScreenRect`][110.5] for fission compatible. +- ⚠️ Deprecated [`Autofill.Node.getDimensions`][110.6]. + ([bug 1803733]({{bugzilla}}1803733)) +- Added [`ColorPrompt.predefinedValues`][110.7] to expose predefined values by [`datalist`][110.8] element in the color prompt. + ([bug 1805616]({{bugzilla}}1805616)) + +[110.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerDetected(org.mozilla.geckoview.GeckoSession) +[110.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onCookieBannerHandled(org.mozilla.geckoview.GeckoSession) +[110.3]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html#COOKIE_BANNER_MODE_DETECT_ONLY +[110.4]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeAndPersistInPrivateBrowsingForDomain(java.lang.String,int) +[110.5]: {{javadoc_uri}}/Autofill.Node.html#getScreenRect() +[110.6]: {{javadoc_uri}}/Autofill.Node.html#getDimensions() +[110.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ColorPrompt.html#predefinedValues +[110.8]: https://developer.mozilla.org/en/docs/Web/HTML/Element/datalist + +## v109 +- Added [`SelectionActionDelegate.Selection.screenRect`][109.1] for fission compatible. +- ⚠️ Deprecated [`SelectionActionDelegate.Selection.clientRect`][109.2], + [`BasicSelectionActionDelegate.mTempMatrix`][109.3] and + [`BasicSelectionActionDelegate.mTempRect`][109.4]. + ([bug 1785759]({{bugzilla}}1785759)) +- Added [`StorageController.setCookieBannerModeForDomain`][109.5], [`StorageController.getCookieBannerModeForDomain`][109.6] and [`StorageController.removeCookieBannerModeForDomain`][109.7] see ([bug 1797581]({{bugzilla}}1797581)) + +[109.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#screenRect +[109.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#clientRect +[109.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempMatrix +[109.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#mTempRect +[109.5]: {{javadoc_uri}}/StorageController.html#setCookieBannerModeForDomain(java.lang.String,int,boolean) +[109.6]: {{javadoc_uri}}/StorageController.html#getCookieBannerModeForDomain(java.lang.String,boolean) +[109.7]: {{javadoc_uri}}/StorageController.html#removeCookieBannerModeForDomain(java.lang.String,boolean) + +## v108 +- Added [`ContentBlocking.CookieBannerMode`][108.1]; [`cookieBannerHandlingMode`][108.2] and [`cookieBannerHandlingModePrivateBrowsing`][108.3] to [`ContentBlocking.Settings.Builder`][81.1]; + [`getCookieBannerMode`][108.4], [`setCookieBannerMode`][108.5], [`getCookieBannerModePrivateBrowsing`][108.6] and [`setCookieBannerModePrivateBrowsing`][108.7] to [`ContentBlocking.Settings`][81.2] + ([bug 1790724]({{bugzilla}}1790724)) +- Added [`GeckoSession.GeckoPrintException`][108.9] to improver error reporting while generating a PDF from website, ([bug 1798402]({{bugzilla}}1798402)). +- Added [`GeckoSession.containsFormData`][108.10] that returns a `GeckoResult<Boolean>` for whether or not a session has form data, ([bug 1777506]({{bugzilla}}1777506)). + +[108.1]: {{javadoc_uri}}/ContentBlocking.CookieBannerMode.html +[108.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingMode(int) +[108.3]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieBannerHandlingModePrivateBrowsing(int) +[108.4]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerMode() +[108.5]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerMode(int) +[108.6]: {{javadoc_uri}}/ContentBlocking.Settings.html#getCookieBannerModePrivateBrowsing() +[108.7]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBannerModePrivateBrowsing(int) +[108.9]: {{javadoc_uri}}/GeckoSession.GeckoPrintException.html +[108.10]: {{javadoc_uri}}/GeckoSession.html#containsFormData() + +## v107 +- Removed deprecated [`cookieLifetime`][103.2] +- Removed deprecated `setPermission`, see deprecation note in [v90](#v90) + +## v106 +- Added [`SelectionActionDelegate.onShowClipboardPermissionRequest`][106.1], + [`SelectionActionDelegate.onDismissClipboardPermissionRequest`][106.2], + [`BasicSelectionActionDelegate.onShowClipboardPermissionRequest`][106.3], + [`BasicSelectionActionDelegate.onDismissCancelClipboardPermissionRequest`][106.4] and + [`SelectionActionDelegate.ClipboardPermission`][106.5] to handle permission + request for reading clipboard data by [`clipboard.readText`][106.6]. + ([bug 1776829]({{bugzilla}}1776829)) + +[106.1]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.2]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onDismissClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession) +[106.3]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowClipboardPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.ClipboardPermission) +[106.4]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onDismissClipboardPermission(org.mozilla.geckoview.GeckoSession) +[106.5]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.ClipboardPermission.html +[106.6]: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/readText + +## v104 +- Removed deprecated Autofill.Delegate `onAutofill`, Autofill.Node `fillViewStructure`, `getFocused`, `getId`, `getValue`, `getVisible`, Autofill.NodeData `Autofill.Notify`, Autofill.Session `surfaceChanged`. + ([bug 1781180]({{bugzilla}}1781180)) +- Removed deprecated `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5] +- Removed deprecated [`GeckoSession.autofill`][102.18]. + ([bug 1781180]({{bugzilla}}1781180)) +- Removed deprecated [`onLocationChange(2)`][102.3] + ([bug 1781180]({{bugzilla}}1781180)) + +## v103 +- Added [`GeckoSession.saveAsPdf`][103.1] that returns a `GeckoResult<InputStream>` that contains a PDF of the current session's page. +- Added missing `@Deprecated` tag for `setPermission`, see deprecation note in [v90](#v90). +- ⚠️ Deprecated [`cookieLifetime`][103.2], this feature is not available anymore. + +[103.1]: {{javadoc_uri}}/GeckoSession.html#saveAsPdf() +[103.2]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html#cookieLifetime(int) + +## v102 +- Added [`DateTimePrompt.stepValue`][102.1] to export [`step`][102.2] attribute of input element. + ([bug 1499635]({{bugzilla}}1499635)) +- Deprecated [`onLocationChange(2)`][102.3], please use [`onLocationChange(3)`][102.4]. +- Added [`GeckoSession.setPriorityHint`][102.5] function to set the session to either high priority or default. +- [`WebRequestError.ERROR_HTTPS_ONLY`][102.6] now has error category + `ERROR_CATEGORY_NETWORK` rather than `ERROR_CATEGORY_SECURITY`. +- ⚠️ The Autofill.Delegate API now receives a [`AutofillNode`][102.7] object instead of + the entire [`Node`][102.8] structure. The `onAutofill` delegate method is now split + into several methods: [`onNodeAdd`][102.9], [`onNodeBlur`][102.10], + [`onNodeFocus`][102.11], [`onNodeRemove`][102.12], [`onNodeUpdate`][102.13], + [`onSessionCancel`][102.14], [`onSessionCommit`][102.15], + [`onSessionStart`][102.16]. +- Added [`PromptInstanceDelegate.onPromptUpdate`][102.17] to allow GeckoView to update current prompts. + ([bug 1758800]({{bugzilla}}1758800)) +- Deprecated [`GeckoSession.autofill`][102.18], use [`Autofill.Session.autofill`][102.19] instead. + ([bug 1770010]({{bugzilla}}1770010)) +- Added [`WebRequestError.ERROR_BAD_HSTS_CERT`][102.20] error code to notify the app of a connection to a site that does not allow error overrides. + ([bug 1721220]({{bugzilla}}1721220)) + +[102.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.DateTimePrompt.html#stepValue +[102.2]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date#step +[102.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[102.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List) +[102.5]: {{javadoc_uri}}/GeckoSession.html#setPriorityHint(int) +[102.6]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY +[102.7]: {{javadoc_uri}}/Autofill.AutofillNode.html +[102.8]: {{javadoc_uri}}/Autofill.Node.html +[102.9]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeAdd(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.10]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeBlur(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.11]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeFocus(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.12]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeRemove(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.13]: {{javadoc_uri}}/Autofill.Delegate.html#onNodeUpdate(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.14]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCancel(org.mozilla.geckoview.GeckoSession) +[102.15]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionCommit(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.Autofill.Node,org.mozilla.geckoview.Autofill.NodeData) +[102.16]: {{javadoc_uri}}/Autofill.Delegate.html#onSessionStart(org.mozilla.geckoview.GeckoSession) +[102.17]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html#onPromptUpdate(org.mozilla.geckoview.GeckoSession.PromptDelegate.BasePrompt) +[102.18]: {{javadoc_uri}}/GeckoSession.html#autofill(android.util.SparseArray) +[102.19]: {{javadoc_uri}}/Autofill.Session.html#autofill(android.util.SparseArray) +[102.20]: {{javadoc_uri}}/WebRequestError.html#ERROR_BAD_HSTS_CERT + +## v101 +- Added [`GeckoDisplay.surfaceChanged`][101.1] function taking new type [`GeckoDisplay.SurfaceInfo`][101.2]. + This allows the caller to provide a [`SurfaceControl`][101.3] object, which must be set on SDK level 29 and + above when rendering in to a `SurfaceView`. + ([bug 1762424]({{bugzilla}}1762424)) +- ⚠️ Deprecated old `GeckoDisplay.surfaceChanged` functions [[1]][101.4] [[2]][101.5]. +- Add [`WebExtensionController.optionalPrompt`][101.6] to allow handling of optional permission requests from extensions. + +[101.1]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(org.mozilla.geckoview.GeckoDisplay.SurfaceInfo) +[101.2]: {{javadoc_uri}}/GeckoDisplay.SurfaceInfo.html +[101.3]: https://developer.android.com/reference/android/view/SurfaceControl +[101.4]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int) +[101.5]: {{javadoc_uri}}/GeckoDisplay.html#surfaceChanged(android.view.Surface,int,int,int,int) +[101.6]: {{javadoc_uri}}/WebExtensionController.html#optionalPrompt(org.mozilla.geckoview.WebExtension.Message,org.mozilla.geckoview.WebExtension) + +## v100 +- ⚠️ Changed [`GeckoSession.isOpen`][100.1] to `@UiThread`. +- [`WebNotification`][100.2] now implements [`Parcelable`][100.3] to support + persisting notifications and responding to them while the browser is not + running. +- Removed deprecated `GeckoRuntime.EXTRA_CRASH_FATAL` +- Removed deprecated `MediaSource.rawId` + +[100.1]: {{javadoc_uri}}/GeckoSession.html#isOpen() +[100.2]: {{javadoc_uri}}/WebNotification.html +[100.3]: https://developer.android.com/reference/android/os/Parcelable + +## v99 +- Removed deprecated `GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`. + ([bug 1754244]({{bugzilla}}1754244)) + +## v98 +- Add [`WebRequest.beConservative`][98.1] to allow critical infrastructure to + avoid using bleeding-edge network features. + ([bug 1750231]({{bugzilla}}1750231)) + +[98.1]: {{javadoc_uri}}/WebRequest.html#beConservative + +## v97 +- ⚠️ Deprecated [`MediaSource.rawId`][97.1], + which now provides the same string as [`id`][97.2]. + ([bug 1744346]({{bugzilla}}1744346)) +- Added [`EXTRA_CRASH_PROCESS_TYPE`][97.3] field to `ACTION_CRASHED` intents, + and corresponding [`CRASHED_PROCESS_TYPE_*`][97.4] constants, indicating which + type of process a crash occured in. + ([bug 1743454]({{bugzilla}}1743454)) +- ⚠️ Deprecated [`EXTRA_CRASH_FATAL`][97.5]. Use `EXTRA_CRASH_PROCESS_TYPE` instead. + ([bug 1743454]({{bugzilla}}1743454)) +- Added [`OrientationController`][97.6] to allow GeckoView to handle orientation locking. + ([bug 1697647]({{bugzilla}}1697647)) +- Added [GeckoSession.goBack][97.7] and [GeckoSession.goForward][97.8] with a + `userInteraction` parameter. Updated the default goBack/goForward behaviour + to also be considered as a user interaction. + ([bug 1644595]({{bugzilla}}1644595)) + +[97.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#rawId +[97.2]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html#id +[97.3]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_PROCESS_TYPE +[97.4]: {{javadoc_uri}}/GeckoRuntime.html#CRASHED_PROCESS_TYPE_MAIN +[97.5]: {{javadoc_uri}}/GeckoRuntime.html#EXTRA_CRASH_FATAL +[97.6]: {{javadoc_uri}}/OrientationController.html +[97.7]: {{javadoc_uri}}/GeckoSession.html#goBack(boolean) +[97.8]: {{javadoc_uri}}/GeckoSession.html#goForward(boolean) + +## v96 +- Added [`onLoginFetch`][96.1] which allows apps to provide all saved logins to + GeckoView. + ([bug 1733423]({{bugzilla}}1733423)) +- Added [`GeckoResult.finally_`][96.2] to unconditionally run an action after + the GeckoResult has been completed. + ([bug 1736433]({{bugzilla}}1736433)) +- Added [`ERROR_INVALID_DOMAIN`][96.3] to `WebExtension.InstallException.ErrorCodes`. + ([bug 1740634]({{bugzilla}}1740634)) +- Added [`Selection.pasteAsPlainText`][96.4] to paste HTML content as plain + text. + ([bug 1740414]({{bugzilla}}1740414)) +- Removed deprecated Content Blocking APIs. + ([bug 1743706]({{bugzilla}}1743706)) + +[96.1]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html#onLoginFetch() +[96.2]: {{javadoc_uri}}/GeckoResult.html#finally_(java.lang.Runnable) +[96.3]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_INVALID_DOMAIN +[96.4]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html#pasteAsPlainText() + +## v95 +- Added [`GeckoSession.ContentDelegate.onPointerIconChange()`][95.1] to notify + the application of changing pointer icon. If the application wants to handle + pointer icon, it should override this. + ([bug 1672609]({{bugzilla}}1672609)) +- Deprecated [`ContentBlockingController`][95.2], use + [`StorageController`][95.3] instead. A [`PERMISSION_TRACKING`][95.4] + permission is now present in [`onLocationChange`][95.5] for every page load, + which can be used to set tracking protection exceptions. + ([bug 1714945]({{bugzilla}}1714945)) +- Added [`setPrivateBrowsingPermanentPermission`][95.6], which allows apps to set + permanent permissions in private browsing (e.g. to set permanent tracking + protection permissions in private browsing). + ([bug 1714945]({{bugzilla}}1714945)) +- Deprecated [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7] due to typo. + ([bug 1708815]({{bugzilla}}1708815)) +- Added [`GeckoRuntimeSettings.Builder.enterpriseRootsEnabled`][95.8] to replace [`GeckoRuntimeSettings.Builder.enterpiseRootsEnabled`][95.7]. + ([bug 1708815]({{bugzilla}}1708815)) +- Added [`GeckoSession.ContentDelegate.onPreviewImage`][95.9] to notify + the application of a preview image URL. + ([bug 1732219]({{bugzilla}}1732219)) + +[95.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPointerIconChange(org.mozilla.geckoview.GeckoSession,android.view.PointerIcon) +[95.2]: {{javadoc_uri}}/ContentBlockingController.html +[95.3]: {{javadoc_uri}}/StorageController.java +[95.4]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_TRACKING +[95.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String,java.util.List) +[95.6]: {{javadoc_uri}}/StorageController.html#setPrivateBrowsingPermanentPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int) +[95.7]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpiseRootsEnabled(boolean) +[95.8]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#enterpriseRootsEnabled(boolean) +[95.9]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPreviewImage(org.mozilla.geckoview.GeckoSession,java.lang.String) + +## v94 +- Extended [`Autocomplete`][78.7] API to support credit card saving. + ([bug 1703976]({{bugzilla}}1703976)) + +## v93 +- Removed deprecated [`Autocomplete.LoginStorageDelegate`][78.8]. + ([bug 1725469]({{bugzilla}}1725469)) +- Removed deprecated [`GeckoRuntime.getProfileDir`][90.5]. + ([bug 1725469]({{bugzilla}}1725469)) +- Added [`PromptInstanceDelegate`][93.1] to allow GeckoView to dismiss stale prompts. + ([bug 1710668]({{bugzilla}}1710668)) +- Added [`WebRequestError.ERROR_HTTPS_ONLY`][93.2] error code to allow GeckoView display custom HTTPS-only error pages and bypass them. + ([bug 1697866]({{bugzilla}}1697866)) + +[93.1]: {{javadoc_uri}}/GeckoSession.PromptDelegate.PromptInstanceDelegate.html +[93.2]: {{javadoc_uri}}/WebRequestError.html#ERROR_HTTPS_ONLY + +## v92 +- Added [`PermissionDelegate.PERMISSION_STORAGE_ACCESS`][92.1] to + control the allowing of third-party frames to access first-party cookies and + storage. ([bug 1543720]({{bugzilla}}1543720)) +- Added [`ContentDelegate.onShowDynamicToolbar`][92.2] to notify + the app that it must fully-expand its dynamic toolbar ([bug 1690296]({{bugzilla}}1690296)) +- Removed deprecated `GeckoResult.ALLOW` and `GeckoResult.DENY`. + Use [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9] instead. + +[92.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_STORAGE_ACCESS +[92.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onShowDynamicToolbar(org.mozilla.geckoview.GeckoSession) + +## v91 +- Extended [`Autocomplete`][78.7] API to support addresses. + ([bug 1699794]({{bugzilla}}1699794)). +- Added [`clearDataFromBaseDomain`][91.1] to [`StorageController`][90.2] for + clearing site data by base domain. This includes data of associated subdomains + and data partitioned via [`State Partitioning`][91.3]. +- Removed deprecated `MediaElement` API. + +[91.1]: {{javadoc_uri}}/StorageController.html#clearDataFromBaseDomain(java.lang.String,long) +[91.2]: {{javadoc_uri}}/StorageController.html +[91.3]: https://developer.mozilla.org/en-US/docs/Web/Privacy/State_Partitioning + +## v90 +- Added [`WebNotification.silent`][90.1] and [`WebNotification.vibrate`][90.2] + support. See also [Web/API/Notification/silent][90.3] and + [Web/API/Notification/vibrate][90.4]. + ([bug 1696145]({{bugzilla}}1696145)) +- ⚠️ Deprecated [`GeckoRuntime.getProfileDir`][90.5], the API is being kept for + compatibility but it always returns null. +- Added [`forceEnableAccessibility`][90.6] runtime setting to enable + accessibility during testing. + ([bug 1701269]({{bugzilla}}1701269)) +- Removed deprecated [`GeckoView.onTouchEventForResult`][88.4]. + ([bug 1706403]({{bugzilla}}1706403)) +- ⚠️ Updated [`onContentPermissionRequest`][90.7] to use [`ContentPermission`][90.8]; added + [`setPermission`][90.9] to [`StorageController`][90.10] for modifying existing permissions, and + allowed Gecko to handle persisting permissions. +- ⚠️ Added a deprecation schedule to most existing content blocking exception functionality; + other than [`addException`][90.11], content blocking exceptions should be treated as content + permissions going forward. + +[90.1]: {{javadoc_uri}}/WebNotification.html#silent +[90.2]: {{javadoc_uri}}/WebNotification.html#vibrate +[90.3]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/silent +[90.4]: https://developer.mozilla.org/en-US/docs/Web/API/Notification/vibrate +[90.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfileDir() +[90.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setForceEnableAccessibility(boolean) +[90.7]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission) +[90.8]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html +[90.9]: {{javadoc_uri}}/StorageController.html#setPermission(org.mozilla.geckoview.GeckoSession.PermissionDelegate.ContentPermission,int) +[90.10]: {{javadoc_uri}}/StorageController.html +[90.11]: {{javadoc_uri}}/ContentBlockingController.html#addException(org.mozilla.geckoview.GeckoSession) + +## v89 +- Added [`ContentPermission`][89.1], which is used to report what permissions content + is loaded with in `onLocationChange`. +- Added [`StorageController.getPermissions`][89.2] and [`StorageController.getAllPermissions`][89.3], + allowing inspection of what permissions have been set for a given URI and for all URIs. +- ⚠️ Deprecated [`NavigationDelegate.onLocationChange`][89.4], to be removed in v92. The + new `onLocationChange` callback simply adds permissions information, migration of existing + functionality should only require updating the function signature. +- Added [`GeckoRuntimeSettings.setEnterpriseRootsEnabled`][89.5] which allows + GeckoView to add third party certificate roots from the Android OS CA store. + ([bug 1678191]({{bugzilla}}1678191)). +- ⚠️ [`GeckoSession.load`][89.6] now throws `IllegalArgumentException` if the + session has no [`GeckoSession.NavigationDelegate`][89.7] and the request's `data` URI is too long. + If a `GeckoSession` *does* have a `GeckoSession.NavigationDelegate` and `GeckoSession.load` is called + with a top-level `data` URI that is too long, [`NavigationDelgate.onLoadError`][89.8] will be called + with a [`WebRequestError`][89.9] containing error code [`WebRequestError.ERROR_DATA_URI_TOO_LONG`][89.10]. + ([bug 1668952]({{bugzilla}}1668952)) +- Extended [`Autocomplete`][78.7] API to support credit cards. + ([bug 1691819]({{bugzilla}}1691819)). +- ⚠️ Deprecated [`Autocomplete.LoginStorageDelegate`][78.8] with the intention + of removing it in GeckoView v93. Please use + [`Autocomplete.StorageDelegate`][89.11] instead. + ([bug 1691819]({{bugzilla}}1691819)). +- Added [`ALLOWED_TRACKING_CONTENT`][89.12] to content blocking API to indicate + when unsafe content is allowed by a shim. + ([bug 1661330]({{bugzilla}}1661330)) +- ⚠️ Added [`setCookieBehaviorPrivateMode`][89.13] to control cookie behavior for private browsing + mode independently of normal browsing mode. To maintain current behavior, set this to the same + value as [`setCookieBehavior`][89.14] is set to. + +[89.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.ContentPermission.html +[89.2]: {{javadoc_uri}}/StorageController.html#getPermissions(java.lang.String) +[89.3]: {{javadoc_uri}}/StorageController.html#getAllPermissions() +[89.4]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLocationChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[89.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setEnterpriseRootsEnabled(boolean) +[89.6]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader) +[89.7]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html +[89.8]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadError(org.mozilla.geckoview.GeckoSession,java.lang.String,org.mozilla.geckoview.WebRequestError) +[89.9]: {{javadoc_uri}}/WebRequestError.html +[89.10]: {{javadoc_uri}}/WebRequestError.html#ERROR_DATA_URI_TOO_LONG +[89.11]: {{javadoc_uri}}/Autocomplete.StorageDelegate.html +[89.12]: {{javadoc_uri}}/ContentBlockingController.Event.html#ALLOWED_TRACKING_CONTENT +[89.13]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehaviorPrivateMode(int) +[89.14]: {{javadoc_uri}}/ContentBlocking.Settings.html#setCookieBehavior(int) + +## v88 +- Added [`WebExtension.Download#update`][88.1] that can be used to + implement the WebExtension `downloads` API. This method is used to communicate + updates in the download status to the Web Extension +- Added [`PanZoomController.onTouchEventForDetailResult`][88.2] and + [`GeckoView.onTouchEventForDetailResult`][88.3] to tell information + that the website doesn't expect browser apps to react the event, + also and deprecated [`PanZoomController.onTouchEventForResult`][88.4] + and [`GeckoView.onTouchEventForResult`][88.5]. With these new methods + browser apps can differentiate cases where the browser can do something + the browser's specific behavior in response to the event (e.g. + pull-to-refresh) and cases where the browser should not react to the event + because the event was consumed in the web site (e.g. in canvas like + web apps). + ([bug 1678505]({{bugzilla}}1678505)). +- ⚠️ Deprecate the [`MediaElement`][65.11] API to be removed in v91. + Please use [`MediaSession`][81.6] for media events and control. + ([bug 1693584]({{bugzilla}}1693584)). +- ⚠️ Deprecate [`GeckoResult.ALLOW`][89.6] and [`GeckoResult.DENY`][89.7] in + favor of [`GeckoResult.allow`][89.8] and [`GeckoResult.deny`][89.9]. + ([bug 1697270]({{bugzilla}}1697270)). +- ⚠️ Update [`SessionState`][88.10] to handle null states/strings more gracefully. + ([bug 1685486]({{bugzilla}}1685486)). + +[88.1]: {{javadoc_uri}}/WebExtension.Download.html#update(org.mozilla.geckoview.WebExtension.Download.Info) +[88.2]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForDetailResult +[88.3]: {{javadoc_uri}}/GeckoView.html#onTouchEventForDetailResult +[88.4]: {{javadoc_uri}}/PanZoomController.html#onTouchEventForResult +[88.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult +[88.6]: {{javadoc_uri}}/GeckoResult.html#ALLOW +[88.7]: {{javadoc_uri}}/GeckoResult.html#DENY +[88.8]: {{javadoc_uri}}/GeckoResult.html#allow() +[88.9]: {{javadoc_uri}}/GeckoResult.html#deny() +[88.10]: {{javadoc_uri}}/GeckoSession.SessionState.html + +## v87 +- ⚠️ Added [`WebExtension.DownloadInitData`][87.1] class that can be used to + implement the WebExtension `downloads` API. This class represents initial state of a download. +- Added [`WebExtension.Download.Info`][87.2] interface that can be used to + implement the WebExtension `downloads` API. This interface allows communicating + download's state to Web Extension. +- [`Image#getBitmap`][87.3] now throws [`ImageProcessingException`][87.4] if + the image cannot be processed. + ([bug 1689745]({{bugzilla}}1689745)) +- Added support for HTTPS-only mode to [`GeckoRuntimeSettings`][87.5] via + [`setAllowInsecureConnections`][87.6]. +- Removed `JSONException` throws from [`SessionState.fromString`][87.7], fixed annotations, + and clarified null-handling a bit. + +[87.1]: {{javadoc_uri}}/WebExtension.DownloadInitData.html +[87.2]: {{javadoc_uri}}/WebExtension.Download.Info.html +[87.3]: {{javadoc_uri}}/Image.html#getBitmap(int) +[87.4]: {{javadoc_uri}}/Image.ImageProcessingException.html +[87.5]: {{javadoc_uri}}/GeckoRuntimeSettings.html +[87.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAllowInsecureConnections(int) +[87.7]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String) + +## v86 +- Removed deprecated `ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`. + Use [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] instead. + ([bug 1665157]({{bugzilla}}1665157)) +- Added [`WebExtension.DownloadDelegate`][86.1] and that can be used to + implement the WebExtension `downloads` API. + ([bug 1656336]({{bugzilla}}1656336)) +- Added [`WebRequest.Builder#body(@Nullable String)`][86.2] which converts a string to direct byte buffer. +- Removed deprecated `REPLACED_UNSAFE_CONTENT`. + ([bug 1667471]({{bugzilla}}1667471)) +- Removed deprecated [`GeckoSession#loadUri`][83.6] variants in favor of + [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8]. + ([bug 1667471]({{bugzilla}}1667471)) +- Added [`GeckoResult#map`][86.3] to synchronously map a GeckoResult value. +- Added [`PanZoomController#INPUT_RESULT_IGNORED`][86.4]. + ([bug 1687430]({{bugzilla}}1687430)) + +[86.1]: {{javadoc_uri}}/WebExtension.DownloadDelegate.html +[86.2]: {{javadoc_uri}}/WebRequest.Builder#body(java.lang.String) +[86.3]: {{javadoc_uri}}/GeckoResult.html#map(org.mozilla.geckoview.GeckoResult.OnValueMapper) +[86.4]: {{javadoc_uri}}/PanZoomController.html#INPUT_RESULT_IGNORED + +## v85 +- Added [`WebExtension.BrowsingDataDelegate`][85.1] that can be used to + implement the WebExtension `browsingData` API. + +[85.1]: {{javadoc_uri}}/WebExtension.BrowsingDataDelegate.html + +## v84 +- ⚠️ Removed deprecated `GeckoRuntimeSettings.Builder.useMultiprocess` and + [`GeckoRuntimeSettings.getUseMultiprocess`]. Single-process GeckoView is no + longer supported. ([bug 1650118]({{bugzilla}}1650118)) +- Deprecated members now have an additional [`@DeprecationSchedule`][84.1] annotation which + includes the `version` that we expect to remove the member and an `id` that + can be used to group annotation notices in tooling. + ([bug 1671460]({{bugzilla}}1671460)) +- ⚠️ Removed deprecated `ContentBlockingController.ExceptionList` and + `ContentBlockingController.restoreExceptionList`. ([bug 1674500]({{bugzilla}}1674500)) + +[84.1]: {{javadoc_uri}}/DeprecationSchedule.html + +## v83 +- Added [`WebExtension.MetaData.temporary`][83.1] which exposes whether an extension + has been installed temporarily, e.g. when using web-ext. + ([bug 1624410]({{bugzilla}}1624410)) +- ⚠️ Removing unsupported `MediaSession.Delegate.onPictureInPicture` for now. + Also, [`MediaSession.Delegate.onMetadata`][83.2] is no longer dispatched for + plain media elements. + ([bug 1658937]({{bugzilla}}1658937)) +- Replaced android.util.ArrayMap with java.util.TreeMap in [`WebMessage`][65.13] to enable case-insensitive handling of the HTTP headers. + ([bug 1666013]({{bugzilla}}1666013)) +- Added [`ContentBlocking.SafeBrowsingProvider`][83.3] to configure Safe + Browsing providers. + ([bug 1660241]({{bugzilla}}1660241)) +- Added [`GeckoRuntime.ActivityDelegate`][83.4] which allows applications to handle + starting external Activities on behalf of GeckoView. Currently this is used to integrate + FIDO support for WebAuthn. +- Added [`GeckoWebExecutor#FETCH_FLAG_PRIVATE`][83.5]. This new flag allows for private browsing downloads using WebExecutor. + ([bug 1665426]({{bugzilla}}1665426)) +- ⚠️ Deprecated [`GeckoSession#loadUri`][83.6] variants in favor of + [`GeckoSession#load`][83.7]. See docs for [`Loader`][83.8]. + ([bug 1667471]({{bugzilla}}1667471)) +- Added [`Loader#headerFilter`][83.9] to override the default header filtering + behavior. + ([bug 1667471]({{bugzilla}}1667471)) + +[83.1]: {{javadoc_uri}}/WebExtension.MetaData.html#temporary +[83.2]: {{javadoc_uri}}/MediaSession.Delegate.html#onMetadata(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.MediaSession,org.mozilla.geckoview.MediaSession.Metadata) +[83.3]: {{javadoc_uri}}/ContentBlocking.SafeBrowsingProvider.html +[83.4]: {{javadoc_uri}}/GeckoRuntime.ActivityDelegate.html +[83.5]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAG_PRIVATE +[83.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int,java.util.Map) +[83.7]: {{javadoc_uri}}/GeckoSession.html#load(org.mozilla.geckoview.GeckoSession.Loader) +[83.8]: {{javadoc_uri}}/GeckoSession.Loader.html +[83.9]: {{javadoc_uri}}/GeckoSession.Loader.html#headerFilter(int) + +## v82 +- ⚠️ [`WebNotification.source`][79.2] is now `@Nullable` to account for + WebExtension notifications which don't have a `source` field. +- ⚠️ Deprecated [`ContentDelegate#onExternalResponse(GeckoSession, WebResponseInfo)`][82.1] with the intention of removing + them in GeckoView v85. + ([bug 1530022]({{bugzilla}}1530022)) +- Added [`ContentDelegate#onExternalResponse(GeckoSession, WebResponse)`][82.2] to eliminate the need + to make a second request for downloads and ensure more efficient and reliable downloads in a single request. The second + parameter is now a [`WebResponse`][65.15] + ([bug 1530022]({{bugzilla}}1530022)) +- Added [`Image`][82.3] support for size-dependent bitmap retrieval from image resources. + ([bug 1658456]({{bugzilla}}1658456)) +- ⚠️ Use [`Image`][82.3] for [`MediaSession`][81.6] artwork and [`WebExtension`][69.5] icon support. + ([bug 1662508]({{bugzilla}}1662508)) +- Added [`RepostConfirmPrompt`][82.4] to prompt the user for cofirmation before + resending POST requests. + ([bug 1659073]({{bugzilla}}1659073)) +- Removed `Parcelable` support in `GeckoSession`. Use [`ProgressDelegate#onSessionStateChange`][68.29] and [`ProgressDelegate#restoreState`][82.5] instead. + ([bug 1650108]({{bugzilla}}1650108)) +- ⚠️ Use AndroidX instead of the Android support library. For the public API this only changes + the thread and nullable annotation types. +- Added [`REPLACED_TRACKING_CONTENT`][82.6] to content blocking API to indicate when unsafe content is shimmed. + ([bug 1663756]({{bugzilla}}1663756)) + +[82.1]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.WebResponseInfo) +[82.2]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onExternalResponse(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoResult) +[82.3]: {{javadoc_uri}}/Image.html +[82.4]: {{javadoc_uri}}/GeckoSession.PromptDelegate.RepostConfirmPrompt.html +[82.5]: {{javadoc_uri}}/GeckoSession.html#restoreState(org.mozilla.geckoview.GeckoSession.SessionState) +[82.6]: {{javadoc_uri}}/ContentBlockingController.Event.html#REPLACED_TRACKING_CONTENT + +## v81 +- Added `cookiePurging` to [`ContentBlocking.Settings.Builder`][81.1] and `getCookiePurging` and `setCookiePurging` + to [`ContentBlocking.Settings`][81.2]. +- Added [`GeckoSession.ContentDelegate.onPaintStatusReset()`][81.3] callback which notifies when valid content is no longer being rendered. +- Made [`GeckoSession.ContentDelegate.onFirstContentfulPaint()`][81.4] additionally be called for the first contentful paint following a `onPaintStatusReset()` event, rather than just the first contentful paint of the session. +- Removed deprecated `GeckoRuntime.registerWebExtension`. Use [`WebExtensionController.install`][73.1] instead. +⚠️ - Changed [`GeckoView.onTouchEventForResult`][81.5] to return a `GeckoResult`, as it now +makes a round-trip to Gecko. The result will be more accurate now, since how content treats +the event is now considered. +- Added [`MediaSession`][81.6] API for session-based media events and control. + +[81.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html +[81.2]: {{javadoc_uri}}/ContentBlocking.Settings.html +[81.3]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onPaintStatusReset(org.mozilla.geckoview.GeckoSession) +[81.4]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession) +[81.5]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent) +[81.6]: {{javadoc_uri}}/MediaSession.html + +## v80 +- Removed `GeckoSession.hashCode` and `GeckoSession.equals` overrides in favor + of the default implementations. ([bug 1647883]({{bugzilla}}1647883)) +- Added `strictSocialTrackingProtection` to [`ContentBlocking.Settings.Builder`][80.1] and `getStrictSocialTrackingProtection` + to [`ContentBlocking.Settings`][80.2]. + +[80.1]: {{javadoc_uri}}/ContentBlocking.Settings.Builder.html +[80.2]: {{javadoc_uri}}/ContentBlocking.Settings.html + +## v79 +- Added `runtime.openOptionsPage` support. For `options_ui.open_in_new_tab == + false`, [`TabDelegate.onOpenOptionsPage`][79.1] is called. + ([bug 1618058]({{bugzilla}}1619766)) +- Added [`WebNotification.source`][79.2], which is the URL of the page + or Service Worker that created the notification. +- Removed deprecated `WebExtensionController.setTabDelegate` and `WebExtensionController.getTabDelegate` + APIs ([bug 1618987]({{bugzilla}}1618987)). +- ⚠️ [`RuntimeTelemetry#getSnapshots`][68.10] is removed after deprecation. + Use Glean to handle Gecko telemetry. + ([bug 1644447]({{bugzilla}}1644447)) +- Added [`ensureBuiltIn`][79.3] that ensures that a built-in extension is + installed without re-installing. + ([bug 1635564]({{bugzilla}}1635564)) +- Added [`ProfilerController`][79.4], accessible via [`GeckoRuntime.getProfilerController`][79.5] +to allow adding gecko profiler markers. +([bug 1624993]({{bugzilla}}1624993)) +- ⚠️ Deprecated `Parcelable` support in `GeckoSession` with the intention of removing + in GeckoView v82. ([bug 1649529]({{bugzilla}}1649529)) +- ⚠️ Deprecated [`GeckoRuntimeSettings.Builder.useMultiprocess`][79.6] and + [`GeckoRuntimeSettings.getUseMultiprocess`][79.7] with the intention of removing + them in GeckoView v82. ([bug 1649530]({{bugzilla}}1649530)) + +[79.1]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onOpenOptionsPage(org.mozilla.geckoview.WebExtension) +[79.2]: {{javadoc_uri}}/WebNotification.html#source +[79.3]: {{javadoc_uri}}/WebExtensionController.html#ensureBuiltIn(java.lang.String,java.lang.String) +[79.4]: {{javadoc_uri}}/ProfilerController.html +[79.5]: {{javadoc_uri}}/GeckoRuntime.html#getProfilerController() +[79.6]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean) +[79.7]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getUseMultiprocess() + +## v78 +- Added [`WebExtensionController.installBuiltIn`][78.1] that allows installing an + extension that is bundled with the APK. This method is meant as a replacement + for [`GeckoRuntime.registerWebExtension`][67.15], ⚠️ which is now deprecated + and will be removed in GeckoView 81. +- Added [`CookieBehavior.ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS`][78.2] to allow + enabling dynamic first party isolation; this will block tracking cookies and + isolate all other third party cookies by keying them based on the first party + from which they are accessed. +- Added `cookieStoreId` field to [`WebExtension.CreateTabDetails`][78.3]. This adds the optional + ability to create a tab with a given cookie store ID for its [`contextual identity`][78.4]. + ([bug 1622500]({{bugzilla}}1622500)) +- Added [`NavigationDelegate.onSubframeLoadRequest`][78.5] to allow intercepting + non-top-level navigations. +- Added [`BeforeUnloadPrompt`][78.6] to respond to prompts from onbeforeunload. +- ⚠️ Refactored `LoginStorage` to the [`Autocomplete`][78.7] API to support + login form autocomplete delegation. + Refactored `LoginStorage.Delegate` to [`Autocomplete.LoginStorageDelegate`][78.8]. + Refactored `GeckoSession.PromptDelegate.onLoginStoragePrompt` to + [`GeckoSession.PromptDelegate.onLoginSave`][78.9]. + Added [`GeckoSession.PromptDelegate.onLoginSelect`][78.10]. + ([bug 1618058]({{bugzilla}}1618058)) +- Added [`GeckoRuntimeSettings#setLoginAutofillEnabled`][78.11] to control + whether login forms should be automatically filled in suitable situations. + +[78.1]: {{javadoc_uri}}/WebExtensionController.html#installBuiltIn(java.lang.String) +[78.2]: {{javadoc_uri}}/ContentBlocking.CookieBehavior.html#ACCEPT_FIRST_PARTY_AND_ISOLATE_OTHERS +[78.3]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html +[78.4]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/contextualIdentities +[78.5]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onSubframeLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest) +[78.6]: {{javadoc_uri}}/GeckoSession.PromptDelegate.BeforeUnloadPrompt.html +[78.7]: {{javadoc_uri}}/Autocomplete.html +[78.8]: {{javadoc_uri}}/Autocomplete.LoginStorageDelegate.html +[78.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSave(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest) +[78.10]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginSelect(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.AutocompleteRequest) +[78.11]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setLoginAutofillEnabled(boolean) + +## v77 +- Added [`GeckoRuntime.appendAppNotesToCrashReport`][77.1] For adding app notes to the crash report. + ([bug 1626979]({{bugzilla}}1626979)) +- ⚠️ Remove the `DynamicToolbarAnimator` API along with accesors on `GeckoView` and `GeckoSession`. + ([bug 1627716]({{bugzilla}}1627716)) + +[77.1]: {{javadoc_uri}}/GeckoRuntime.html#appendAppNotesToCrashReport(java.lang.String) + +## v76 +- Added [`GeckoSession.PermissionDelegate.PERMISSION_MEDIA_KEY_SYSTEM_ACCESS`][76.1] to control EME media key access. +- [`RuntimeTelemetry#getSnapshots`][68.10] is deprecated and will be removed + in 79. Use Glean to handle Gecko telemetry. + ([bug 1620395]({{bugzilla}}1620395)) +- Added `LoadRequest.isDirectNavigation` to know when calls to + [`onLoadRequest`][76.3] originate from a direct navigation made by the app + itself. + ([bug 1624675]({{bugzilla}}1624675)) + +[76.1]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_MEDIA_KEY_SYSTEM_ACCESS +[76.2]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isDirectNavigation +[76.3]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.html#onLoadRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest) + +## v75 +- ⚠️ Remove `GeckoRuntimeSettings.Builder#useContentProcessHint`. The content + process is now preloaded by default if + [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1] is enabled. +- ⚠️ Move `GeckoSessionSettings.Builder#useMultiprocess` to + [`GeckoRuntimeSettings.Builder#useMultiprocess`][75.1]. Multiprocess state is + no longer determined per session. +- Added [`DebuggerDelegate#onExtensionListUpdated`][75.2] to notify that a temporary + extension has been installed by the debugger. + ([bug 1614295]({{bugzilla}}1614295)) +- ⚠️ Removed [`GeckoRuntimeSettings.setAutoplayDefault`][75.3], use + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13] to + control autoplay. + ([bug 1614894]({{bugzilla}}1614894)) +- Added [`GeckoSession.reload(int flags)`][75.4] That takes a [load flag][75.5] parameter. +- ⚠️ Moved [`ActionDelegate`][75.6] and [`MessageDelegate`][75.7] to + [`SessionController`][75.8]. + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`SessionTabDelegate`][75.9] to [`SessionController`][75.8] and + [`TabDelegate`][75.10] to [`WebExtension`][69.5] which receive respectively + calls for the session and the runtime. `TabDelegate` is also now + per-`WebExtension` object instead of being global. The existing global + [`TabDelegate`][75.11] is now deprecated and will be removed in GeckoView 77. + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`SessionTabDelegate#onUpdateTab`][75.12] which is called whenever an + extension calls `tabs.update` on the corresponding `GeckoSession`. + [`TabDelegate#onCreateTab`][75.13] now takes a [`CreateTabDetails`][75.14] + object which contains additional information about the newly created tab + (including the `url` which used to be passed in directly). + ([bug 1616625]({{bugzilla}}1616625)) +- Added [`GeckoRuntimeSettings.setWebManifestEnabled`][75.15], + [`GeckoRuntimeSettings.webManifest`][75.16], and + [`GeckoRuntimeSettings.getWebManifestEnabled`][75.17] + ([bug 1614894]({{bugzilla}}1603673)), to enable or check Web Manifest support. +- Added [`GeckoDisplay.safeAreaInsetsChanged`][75.18] to notify the content of [safe area insets][75.19]. + ([bug 1503656]({{bugzilla}}1503656)) +- Added [`GeckoResult#cancel()`][75.22], [`GeckoResult#setCancellationDelegate()`][75.22], + and [`GeckoResult.CancellationDelegate`][75.23]. This adds the optional ability to cancel + an operation behind a pending `GeckoResult`. +- Added [`baseUrl`][75.24] to [`WebExtension.MetaData`][75.25] to expose the + base URL for all WebExtension pages for a given extension. + ([bug 1560048]({{bugzilla}}1560048)) +- Added [`allowedInPrivateBrowsing`][75.26] and + [`setAllowedInPrivateBrowsing`][75.27] to control whether an extension can + run in private browsing or not. Extensions installed with + [`registerWebExtension`][67.15] will always be allowed to run in private + browsing. + ([bug 1599139]({{bugzilla}}1599139)) + +[75.1]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#useMultiprocess(boolean) +[75.2]: {{javadoc_uri}}/WebExtensionController.DebuggerDelegate.html#onExtensionListUpdated() +[75.3]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#autoplayDefault(boolean) +[75.4]: {{javadoc_uri}}/GeckoSession.html#reload(int) +[75.5]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_NONE +[75.6]: {{javadoc_uri}}/WebExtension.ActionDelegate.html +[75.7]: {{javadoc_uri}}/WebExtension.MessageDelegate.html +[75.8]: {{javadoc_uri}}/WebExtension.SessionController.html +[75.9]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html +[75.10]: {{javadoc_uri}}/WebExtension.TabDelegate.html +[75.11]: {{javadoc_uri}}/WebExtensionRuntime.TabDelegate.html +[75.12]: {{javadoc_uri}}/WebExtension.SessionTabDelegate.html#onUpdateTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.WebExtension.UpdateTabDetails) +[75.13]: {{javadoc_uri}}/WebExtension.TabDelegate.html#onNewTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.CreateTabDetails) +[75.14]: {{javadoc_uri}}/WebExtension.CreateTabDetails.html +[75.15]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#setWebManifestEnabled(boolean) +[75.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#webManifest(boolean) +[75.17]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#getWebManifestEnabled() +[75.18]: {{javadoc_uri}}/GeckoDisplay.html#safeAreaInsetsChanged(int,int,int,int) +[75.19]: https://developer.mozilla.org/en-US/docs/Web/CSS/env +[75.20]: {{javadoc_uri}}/WebExtension.InstallException.ErrorCodes.html#ERROR_POSTPONED +[75.21]: {{javadoc_uri}}/GeckoResult.html#cancel() +[75.22]: {{javadoc_uri}}/GeckoResult.html#setCancellationDelegate(CancellationDelegate) +[75.23]: {{javadoc_uri}}/GeckoResult.CancellationDelegate.html +[75.24]: {{javadoc_uri}}/WebExtension.MetaData.html#baseUrl +[75.25]: {{javadoc_uri}}/WebExtension.MetaData.html +[75.26]: {{javadoc_uri}}/WebExtension.MetaData.html#allowedInPrivateBrowsing +[75.27]: {{javadoc_uri}}/WebExtensionController.html#setAllowedInPrivateBrowsing(org.mozilla.geckoview.WebExtension,boolean) + +## v74 +- Added [`WebExtensionController.enable`][74.1] and [`disable`][74.2] to + enable and disable extensions. + ([bug 1599585]({{bugzilla}}1599585)) +- ⚠️ Added [`GeckoSession.ProgressDelegate.SecurityInformation#certificate`][74.3], which is the + full server certificate in use, if any. The other certificate-related fields were removed. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebResponse#isSecure`][74.4], which indicates whether or not the response was + delivered over a secure connection. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebResponse#certificate`][74.5], which is the server certificate used for the + response, if any. + ([bug 1508730]({{bugzilla}}1508730)) +- Added [`WebRequestError#certificate`][74.6], which is the server certificate used in the + failed request, if any. + ([bug 1508730]({{bugzilla}}1508730)) +- ⚠️ Updated [`ContentBlockingController`][74.7] to use new representation for content blocking + exceptions and to add better support for removing exceptions. This deprecates [`ExceptionList`][74.8] + and [`restoreExceptionList`][74.9] with the intent to remove them in 76. + ([bug 1587552]({{bugzilla}}1587552)) +- Added [`GeckoSession.ContentDelegate.onMetaViewportFitChange`][74.10]. This exposes `viewport-fit` value that is CSS Round Display Level 1. ([bug 1574307]({{bugzilla}}1574307)) +- Extended [`LoginStorage.Delegate`][74.11] with [`onLoginUsed`][74.12] to + report when existing login entries are used for autofill. + ([bug 1610353]({{bugzilla}}1610353)) +- Added [`WebExtensionController#setTabActive`][74.13], which is used to notify extensions about + tab changes + ([bug 1597793]({{bugzilla}}1597793)) +- Added [`WebExtension.metaData.optionsUrl`][74.14] and [`WebExtension.metaData.openOptionsPageInTab`][74.15], + which is the addon metadata necessary to show their option pages. + ([bug 1598792]({{bugzilla}}1598792)) +- Added [`WebExtensionController.update`][74.16] to update extensions. ([bug 1599581]({{bugzilla}}1599581)) +- ⚠️ Replaced `subscription` argument in [`WebPushDelegate.onSubscriptionChanged`][74.17] from a [`WebPushSubscription`][74.18] to the [`String`][74.19] `scope`. + +[74.1]: {{javadoc_uri}}/WebExtensionController.html#enable(org.mozilla.geckoview.WebExtension,int) +[74.2]: {{javadoc_uri}}/WebExtensionController.html#disable(org.mozilla.geckoview.WebExtension,int) +[74.3]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html#certificate +[74.4]: {{javadoc_uri}}/WebResponse.html#isSecure +[74.5]: {{javadoc_uri}}/WebResponse.html#certificate +[74.6]: {{javadoc_uri}}/WebRequestError.html#certificate +[74.7]: {{javadoc_uri}}/ContentBlockingController.html +[74.8]: {{javadoc_uri}}/ContentBlockingController.ExceptionList.html +[74.9]: {{javadoc_uri}}/ContentBlockingController.html#restoreExceptionList(org.mozilla.geckoview.ContentBlockingController.ExceptionList) +[74.10]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onMetaViewportFitChange(org.mozilla.geckoview.GeckoSession,java.lang.String) +[74.11]: {{javadoc_uri}}/LoginStorage.Delegate.html +[74.12]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginUsed(org.mozilla.geckoview.LoginStorage.LoginEntry,int) +[74.13]: {{javadoc_uri}}/WebExtensionController.html#setTabActive +[74.14]: {{javadoc_uri}}/WebExtension.MetaData.html#optionsUrl +[74.15]: {{javadoc_uri}}/WebExtension.MetaData.html#openOptionsPageInTab +[74.16]: {{javadoc_uri}}/WebExtensionController.html#update(org.mozilla.geckoview.WebExtension,int) +[74.17]: {{javadoc_uri}}/WebPushController.html#onSubscriptionChange(org.mozilla.geckoview.WebPushSubscription,byte[]) +[74.18]: {{javadoc_uri}}/WebPushSubscription.html +[74.19]: https://developer.android.com/reference/java/lang/String + +## v73 +- Added [`WebExtensionController.install`][73.1] and [`uninstall`][73.2] to + manage installed extensions +- ⚠️ Renamed `ScreenLength.VIEWPORT_WIDTH`, `ScreenLength.VIEWPORT_HEIGHT`, + `ScreenLength.fromViewportWidth` and `ScreenLength.fromViewportHeight` to + [`ScreenLength.VISUAL_VIEWPORT_WIDTH`][73.3], + [`ScreenLength.VISUAL_VIEWPORT_HEIGHT`][73.4], + [`ScreenLength.fromVisualViewportWidth`][73.5] and + [`ScreenLength.fromVisualViewportHeight`][73.6] respectively. +- Added the [`LoginStorage`][73.7] API. Apps may handle login fetch requests now by + attaching a [`LoginStorage.Delegate`][73.8] via + [`GeckoRuntime#setLoginStorageDelegate`][73.9] + ([bug 1602881]({{bugzilla}}1602881)) +- ⚠️ [`WebExtension`][69.5]'s constructor now requires a `WebExtensionController` + instance. +- Added [`GeckoResult.allOf`][73.10] for consuming a list of results. +- Added [`WebExtensionController.list`][73.11] to list all installed extensions. +- Added [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_AUDIBLE`][73.12] and + [`GeckoSession.PermissionDelegate#PERMISSION_AUTOPLAY_INAUDIBLE`][73.13]. These control + autoplay permissions for audible and inaudible videos. + ([bug 1577596]({{bugzilla}}1577596)) +- Added [`LoginStorage.Delegate.onLoginSave`][73.14] for login storage save + requests and [`GeckoSession.PromptDelegate.onLoginStoragePrompt`][73.15] for + login storage prompts. + ([bug 1599873]({{bugzilla}}1599873)) + +[73.1]: {{javadoc_uri}}/WebExtensionController.html#install(java.lang.String) +[73.2]: {{javadoc_uri}}/WebExtensionController.html#uninstall(org.mozilla.geckoview.WebExtension) +[73.3]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_WIDTH +[73.4]: {{javadoc_uri}}/ScreenLength.html#VISUAL_VIEWPORT_HEIGHT +[73.5]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportWidth(double) +[73.6]: {{javadoc_uri}}/ScreenLength.html#fromVisualViewportHeight(double) +[73.7]: {{javadoc_uri}}/LoginStorage.html +[73.8]: {{javadoc_uri}}/LoginStorage.Delegate.html +[73.9]: {{javadoc_uri}}/GeckoRuntime.html#setLoginStorageDelegate(org.mozilla.geckoview.LoginStorage.Delegate) +[73.10]: {{javadoc_uri}}/GeckoResult.html#allOf(java.util.List) +[73.11]: {{javadoc_uri}}/WebExtensionController.html#list() +[73.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_AUDIBLE +[73.13]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_AUTOPLAY_INAUDIBLE +[73.14]: {{javadoc_uri}}/LoginStorage.Delegate.html#onLoginSave(org.mozilla.geckoview.LoginStorage.LoginEntry) +[73.15]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onLoginStoragePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.LoginStoragePrompt) + +## v72 +- Added [`GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture`][72.1]. This indicates + if a load was requested while a user gesture was active (e.g., a tap). + ([bug 1555337]({{bugzilla}}1555337)) +- ⚠️ Refactored `AutofillElement` and `AutofillSupport` into the + [`Autofill`][72.2] API. + ([bug 1591462]({{bugzilla}}1591462)) +- Make `read()` in the `InputStream` returned from [`WebResponse#body`][72.3] timeout according + to [`WebResponse#setReadTimeoutMillis()`][72.4]. The default timeout value is reflected in + [`WebResponse#DEFAULT_READ_TIMEOUT_MS`][72.5], currently 30s. + ([bug 1595145]({{bugzilla}}1595145)) +- ⚠️ Removed `GeckoResponse` + ([bug 1581161]({{bugzilla}}1581161)) +- ⚠️ Removed `actions` and `response` arguments from [`SelectionActionDelegate.onShowActionRequest`][72.6] + and [`BasicSelectionActionDelegate.onShowActionRequest`][72.7] + ([bug 1581161]({{bugzilla}}1581161)) +- Added text selection action methods to [`SelectionActionDelegate.Selection`][72.8] + ([bug 1581161]({{bugzilla}}1581161)) +- Added [`BasicSelectionActionDelegate.getSelection`][72.9] + ([bug 1581161]({{bugzilla}}1581161)) +- Changed [`BasicSelectionActionDelegate.clearSelection`][72.10] to public. + ([bug 1581161]({{bugzilla}}1581161)) +- Added `Autofill` commit support. + ([bug 1577005]({{bugzilla}}1577005)) +- Added [`GeckoView.setViewBackend`][72.11] to set whether GeckoView should be + backed by a [`TextureView`][72.12] or a [`SurfaceView`][72.13]. + ([bug 1530402]({{bugzilla}}1530402)) +- Added support for Browser and Page Action from the WebExtension API. + See [`WebExtension.Action`][72.14]. + ([bug 1530402]({{bugzilla}}1530402)) +- ⚠️ Split [`ContentBlockingController.Event.LOADED_TRACKING_CONTENT`][72.15] into + [`ContentBlockingController.Event.LOADED_LEVEL_1_TRACKING_CONTENT`][72.16] and + [`ContentBlockingController.Event.LOADED_LEVEL_2_TRACKING_CONTENT`][72.17]. +- Replaced `subscription` argument in [`WebPushDelegate.onPushEvent`][72.18] from a [`WebPushSubscription`][72.19] to the [`String`][72.20] `scope`. +- ⚠️ Renamed `WebExtension.ActionIcon` to [`Icon`][72.21]. +- Added [`GeckoWebExecutor#FETCH_FLAGS_STREAM_FAILURE_TEST`][72.22], which is a new + flag used to immediately fail when reading a `WebResponse` body. + ([bug 1594905]({{bugzilla}}1594905)) +- Changed [`CrashReporter#sendCrashReport(Context, File, JSONObject)`][72.23] to + accept a JSON object instead of a Map. Said object also includes the + application name that was previously passed as the fourth argument to the + method, which was thus removed. +- Added WebXR device access permission support, [`PERMISSION_PERSISTENT_XR`][72.24]. + ([bug 1599927]({{bugzilla}}1599927)) + +[72.1]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest#hasUserGesture +[72.2]: {{javadoc_uri}}/Autofill.html +[72.3]: {{javadoc_uri}}/WebResponse.html#body +[72.4]: {{javadoc_uri}}/WebResponse.html#setReadTimeoutMillis(long) +[72.5]: {{javadoc_uri}}/WebResponse.html#DEFAULT_READ_TIMEOUT_MS +[72.6]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection) +[72.7]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#onShowActionRequest(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SelectionActionDelegate.Selection) +[72.8]: {{javadoc_uri}}/GeckoSession.SelectionActionDelegate.Selection.html +[72.9]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#getSelection +[72.10]: {{javadoc_uri}}/BasicSelectionActionDelegate.html#clearSelection +[72.11]: {{javadoc_uri}}/GeckoView.html#setViewBackend(int) +[72.12]: https://developer.android.com/reference/android/view/TextureView +[72.13]: https://developer.android.com/reference/android/view/SurfaceView +[72.14]: {{javadoc_uri}}/WebExtension.Action.html +[72.15]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_TRACKING_CONTENT +[72.16]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_1_TRACKING_CONTENT +[72.17]: {{javadoc_uri}}/ContentBlockingController.Event.html#LOADED_LEVEL_2_TRACKING_CONTENT +[72.18]: {{javadoc_uri}}/WebPushController.html#onPushEvent(org.mozilla.geckoview.WebPushSubscription,byte[]) +[72.19]: {{javadoc_uri}}/WebPushSubscription.html +[72.20]: https://developer.android.com/reference/java/lang/String +[72.21]: {{javadoc_uri}}/WebExtension.Icon.html +[72.22]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_STREAM_FAILURE_TEST +[72.23]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,org.json.JSONObject) +[72.24]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_XR + +## v71 +- Added a content blocking flag for blocked social cookies to [`ContentBlocking`][70.17]. + ([bug 1584479]({{bugzilla}}1584479)) +- Added [`onBooleanScalar`][71.1], [`onLongScalar`][71.2], + [`onStringScalar`][71.3] to [`RuntimeTelemetry.Delegate`][70.12] to support + scalars in streaming telemetry. ⚠️ As part of this change, + `onTelemetryReceived` has been renamed to [`onHistogram`][71.4], and + [`Metric`][71.5] now takes a type parameter. + ([bug 1576730]({{bugzilla}}1576730)) +- Added overloads of [`GeckoSession.loadUri`][71.6] that accept a map of + additional HTTP request headers. + ([bug 1567549]({{bugzilla}}1567549)) +- Added support for exposing the content blocking log in [`ContentBlockingController`][71.7]. + ([bug 1580201]({{bugzilla}}1580201)) +- ⚠️ Added `nativeApp` to [`WebExtension.MessageDelegate.onMessage`][71.8] which + exposes the native application identifier that was used to send the message. + ([bug 1546445]({{bugzilla}}1546445)) +- Added [`GeckoRuntime.ServiceWorkerDelegate`][71.9] set via + [`setServiceWorkerDelegate`][71.10] to support [`ServiceWorkerClients.openWindow`][71.11] + ([bug 1511033]({{bugzilla}}1511033)) +- Added [`GeckoRuntimeSettings.Builder#aboutConfigEnabled`][71.12] to control whether or + not `about:config` should be available. + ([bug 1540065]({{bugzilla}}1540065)) +- Added [`GeckoSession.ContentDelegate.onFirstContentfulPaint`][71.13] + ([bug 1578947]({{bugzilla}}1578947)) +- Added `setEnhancedTrackingProtectionLevel` to [`ContentBlocking.Settings`][71.14]. + ([bug 1580854]({{bugzilla}}1580854)) +- ⚠️ Added [`GeckoView.onTouchEventForResult`][71.15] and modified + [`PanZoomController.onTouchEvent`][71.16] to return how the touch event was handled. This + allows apps to know if an event is handled by touch event listeners in web content. The methods in `PanZoomController` now return `int` instead of `boolean`. +- Added [`GeckoSession.purgeHistory`][71.17] allowing apps to clear a session's history. + ([bug 1583265]({{bugzilla}}1583265)) +- Added [`GeckoRuntimeSettings.Builder#forceUserScalableEnabled`][71.18] to control whether or + not to force user scalable zooming. + ([bug 1540615]({{bugzilla}}1540615)) +- ⚠️ Moved Autofill related methods from `SessionTextInput` and `GeckoSession.TextInputDelegate` + into `GeckoSession` and `AutofillDelegate`. +- Added [`GeckoSession.getAutofillElements()`][71.19], which is a new method for getting + an autofill virtual structure without using `ViewStructure`. It relies on a new class, + [`AutofillElement`][71.20], for representing the virtual tree. +- Added [`GeckoView.setAutofillEnabled`][71.21] for controlling whether or not the `GeckoView` + instance participates in Android autofill. When enabled, this connects an `AutofillDelegate` + to the session it holds. +- Changed [`AutofillElement.children`][71.20] interface to `Collection` to provide + an efficient way to pre-allocate memory when filling `ViewStructure`. +- Added [`GeckoSession.PromptDelegate.onSharePrompt`][71.22] to support the WebShare API. + ([bug 1402369]({{bugzilla}}1402369)) +- Added [`GeckoDisplay.screenshot`][71.23] allowing apps finer grain control over screenshots. + ([bug 1577192]({{bugzilla}}1577192)) +- Added `GeckoView.setDynamicToolbarMaxHeight` to make ICB size static, ICB doesn't include the dynamic toolbar region. + ([bug 1586144]({{bugzilla}}1586144)) + +[71.1]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onBooleanScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.2]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onLongScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.3]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onStringScalar(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.4]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html#onHistogram(org.mozilla.geckoview.RuntimeTelemetry.Metric) +[71.5]: {{javadoc_uri}}/RuntimeTelemetry.Metric.html +[71.6]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,java.io.File,java.util.Map) +[71.7]: {{javadoc_uri}}/ContentBlockingController.html +[71.8]: {{javadoc_uri}}/WebExtension.MessageDelegate.html#onMessage(java.lang.String,java.lang.Object,org.mozilla.geckoview.WebExtension.MessageSender) +[71.9]: {{javadoc_uri}}/GeckoRuntime.ServiceWorkerDelegate.html +[71.10]: {{javadoc_uri}}/GeckoRuntime#setServiceWorkerDelegate(org.mozilla.geckoview.GeckoRuntime.ServiceWorkerDelegate) +[71.11]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/openWindow +[71.12]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#aboutConfigEnabled(boolean) +[71.13]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstContentfulPaint(org.mozilla.geckoview.GeckoSession) +[71.15]: {{javadoc_uri}}/GeckoView.html#onTouchEventForResult(android.view.MotionEvent) +[71.16]: {{javadoc_uri}}/PanZoomController.html#onTouchEvent(android.view.MotionEvent) +[71.17]: {{javadoc_uri}}/GeckoSession.html#purgeHistory() +[71.18]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#forceUserScalableEnabled(boolean) +[71.19]: {{javadoc_uri}}/GeckoSession.html#getAutofillElements() +[71.20]: {{javadoc_uri}}/AutofillElement.html +[71.21]: {{javadoc_uri}}/GeckoView.html#setAutofillEnabled(boolean) +[71.22]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onSharePrompt(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.PromptDelegate.SharePrompt) +[71.23]: {{javadoc_uri}}/GeckoDisplay.html#screenshot() + +## v70 +- Added API for session context assignment + [`GeckoSessionSettings.Builder.contextId`][70.1] and deletion of data related + to a session context [`StorageController.clearDataForSessionContext`][70.2]. + ([bug 1501108]({{bugzilla}}1501108)) +- Removed `setSession(session, runtime)` from [`GeckoView`][70.5]. With this + change, `GeckoView` will no longer manage opening/closing of the + [`GeckoSession`][70.6] and instead leave that up to the app. It's also now + allowed to call [`setSession`][70.10] with a closed `GeckoSession`. + ([bug 1510314]({{bugzilla}}1510314)) +- Added an overload of [`GeckoSession.loadUri()`][70.8] that accepts a + referring [`GeckoSession`][70.6]. This should be used when the URI we're + loading originates from another page. A common example of this would be long + pressing a link and then opening that in a new `GeckoSession`. + ([bug 1561079]({{bugzilla}}1561079)) +- Added capture parameter to [`onFilePrompt`][70.9] and corresponding + [`CAPTURE_TYPE_*`][70.7] constants. + ([bug 1553603]({{bugzilla}}1553603)) +- Removed the obsolete `success` parameter from + [`CrashReporter#sendCrashReport(Context, File, File, String)`][70.3] and + [`CrashReporter#sendCrashReport(Context, File, Map, String)`][70.4]. + ([bug 1570789]({{bugzilla}}1570789)) +- Add `GeckoSession.LOAD_FLAGS_REPLACE_HISTORY`. + ([bug 1571088]({{bugzilla}}1571088)) +- Complete rewrite of [`PromptDelegate`][70.11]. + ([bug 1499394]({{bugzilla}}1499394)) +- Added [`RuntimeTelemetry.Delegate`][70.12] that receives streaming telemetry + data from GeckoView. + ([bug 1566367]({{bugzilla}}1566367)) +- Updated [`ContentBlocking`][70.13] to better report blocked and allowed ETP events. + ([bug 1567268]({{bugzilla}}1567268)) +- Added API for controlling Gecko logging [`GeckoRuntimeSettings.debugLogging`][70.14] + ([bug 1573304]({{bugzilla}}1573304)) +- Added [`WebNotification`][70.15] and [`WebNotificationDelegate`][70.16] for handling Web Notifications. + ([bug 1533057]({{bugzilla}}1533057)) +- Added Social Tracking Protection support to [`ContentBlocking`][70.17]. + ([bug 1568295]({{bugzilla}}1568295)) +- Added [`WebExtensionController`][70.18] and [`WebExtensionController.TabDelegate`][70.19] to handle + [`browser.tabs.create`][70.20] calls by WebExtensions. + ([bug 1539144]({{bugzilla}}1539144)) +- Added [`onCloseTab`][70.21] to [`WebExtensionController.TabDelegate`][70.19] to handle + [`browser.tabs.remove`][70.22] calls by WebExtensions. + ([bug 1565782]({{bugzilla}}1565782)) +- Added onSlowScript to [`ContentDelegate`][70.23] which allows handling of slow and hung scripts. + ([bug 1621094]({{bugzilla}}1621094)) +- Added support for Web Push via [`WebPushController`][70.24], [`WebPushDelegate`][70.25], and + [`WebPushSubscription`][70.26]. +- Added [`ContentBlockingController`][70.27], accessible via [`GeckoRuntime.getContentBlockingController`][70.28] + to allow modification and inspection of a content blocking exception list. + +[70.1]: {{javadoc_uri}}/GeckoSessionSettings.Builder.html#contextId(java.lang.String) +[70.2]: {{javadoc_uri}}/StorageController.html#clearDataForSessionContext(java.lang.String) +[70.3]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.io.File,java.lang.String) +[70.4]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,java.io.File,java.util.Map,java.lang.String) +[70.5]: {{javadoc_uri}}/GeckoView.html +[70.6]: {{javadoc_uri}}/GeckoSession.html +[70.7]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#CAPTURE_TYPE_NONE +[70.8]: {{javadoc_uri}}/GeckoSession.html#loadUri(java.lang.String,org.mozilla.geckoview.GeckoSession,int) +[70.9]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html#onFilePrompt(org.mozilla.geckoview.GeckoSession,java.lang.String,int,java.lang.String[],int,org.mozilla.geckoview.GeckoSession.PromptDelegate.FileCallback) +[70.10]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession) +[70.11]: {{javadoc_uri}}/GeckoSession.PromptDelegate.html +[70.12]: {{javadoc_uri}}/RuntimeTelemetry.Delegate.html +[70.13]: {{javadoc_uri}}/ContentBlocking.html +[70.14]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#debugLogging(boolean) +[70.15]: {{javadoc_uri}}/WebNotification.html +[70.16]: {{javadoc_uri}}/WebNotificationDelegate.html +[70.17]: {{javadoc_uri}}/ContentBlocking.html +[70.18]: {{javadoc_uri}}/WebExtensionController.html +[70.19]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html +[70.20]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/create +[70.21]: {{javadoc_uri}}/WebExtensionController.TabDelegate.html#onCloseTab(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.GeckoSession) +[70.22]: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/remove +[70.23]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html +[70.24]: {{javadoc_uri}}/WebPushController.html +[70.25]: {{javadoc_uri}}/WebPushDelegate.html +[70.26]: {{javadoc_uri}}/WebPushSubscription.html +[70.27]: {{javadoc_uri}}/ContentBlockingController.html +[70.28]: {{javadoc_uri}}/GeckoRuntime.html#getContentBlockingController() + +## v69 +- Modified behavior of [`setAutomaticFontSizeAdjustment`][69.1] so that it no + longer has any effect on [`setFontInflationEnabled`][69.2] +- Add [GeckoSession.LOAD_FLAGS_FORCE_ALLOW_DATA_URI][69.14] +- Added [`GeckoResult.accept`][69.3] for consuming a result without + transforming it. +- [`GeckoSession.setMessageDelegate`][69.13] callers must now specify the + [`WebExtension`][69.5] that the [`MessageDelegate`][69.4] will receive + messages from. +- Created [`onKill`][69.7] to [`ContentDelegate`][69.11] to differentiate from crashes. + +[69.1]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean) +[69.2]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontInflationEnabled(boolean) +[69.3]: {{javadoc_uri}}/GeckoResult.html#accept(org.mozilla.geckoview.GeckoResult.Consumer) +[69.4]: {{javadoc_uri}}/WebExtension.MessageDelegate.html +[69.5]: {{javadoc_uri}}/WebExtension.html +[69.7]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onKill(org.mozilla.geckoview.GeckoSession) +[69.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html +[69.13]: {{javadoc_uri}}/GeckoSession.html#setMessageDelegate(org.mozilla.geckoview.WebExtension,org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String) +[69.14]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_FORCE_ALLOW_DATA_URI + +## v68 +- Added [`GeckoRuntime#configurationChanged`][68.1] to notify the device + configuration has changed. +- Added [`onSessionStateChange`][68.29] to [`ProgressDelegate`][68.2] and removed `saveState`. +- Added [`ContentBlocking#AT_CRYPTOMINING`][68.3] for cryptocurrency miner blocking. +- Added [`ContentBlocking#AT_DEFAULT`][68.4], [`ContentBlocking#AT_STRICT`][68.5], + [`ContentBlocking#CB_DEFAULT`][68.6] and [`ContentBlocking#CB_STRICT`][68.7] + for clearer app default selections. +- Added [`GeckoSession.SessionState.fromString`][68.8]. This can be used to + deserialize a `GeckoSession.SessionState` instance previously serialized to + a `String` via `GeckoSession.SessionState.toString`. +- Added [`GeckoRuntimeSettings#setPreferredColorScheme`][68.9] to override + the default color theme for web content ("light" or "dark"). +- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all fields. +- [`RuntimeTelemetry#getSnapshots`][68.10] returns a [`JSONObject`][68.30] now. +- Removed all `org.mozilla.gecko` references in the API. +- Added [`ContentBlocking#AT_FINGERPRINTING`][68.11] to block fingerprinting trackers. +- Added [`HistoryItem`][68.31] and [`HistoryList`][68.32] interfaces and [`onHistoryStateChange`][68.34] to + [`HistoryDelegate`][68.12] and added [`gotoHistoryIndex`][68.33] to [`GeckoSession`][68.13]. +- [`GeckoView`][70.5] will not create a [`GeckoSession`][65.9] anymore when + attached to a window without a session. +- Added [`GeckoRuntimeSettings.Builder#configFilePath`][68.16] to set + a path to a configuration file from which GeckoView will read + configuration options such as Gecko process arguments, environment + variables, and preferences. +- Added [`unregisterWebExtension`][68.17] to unregister a web extension. +- Added messaging support for WebExtension. [`setMessageDelegate`][68.18] + allows embedders to listen to messages coming from a WebExtension. + [`Port`][68.19] allows bidirectional communication between the embedder and + the WebExtension. +- Expose the following prefs in [`GeckoRuntimeSettings`][67.3]: + [`setAutoZoomEnabled`][68.20], [`setDoubleTapZoomingEnabled`][68.21], + [`setGlMsaaLevel`][68.22]. +- Added new constant for requesting external storage Android permissions, [`PERMISSION_PERSISTENT_STORAGE`][68.35] +- Added `setVerticalClipping` to [`GeckoDisplay`][68.24] and + [`GeckoView`][68.23] to tell Gecko how much of its vertical space is clipped. +- Added [`StorageController`][68.25] API for clearing data. +- Added [`onRecordingStatusChanged`][68.26] to [`MediaDelegate`][68.27] to handle events related to the status of recording devices. +- Removed redundant constants in [`MediaSource`][68.28] + +[68.1]: {{javadoc_uri}}/GeckoRuntime.html#configurationChanged(android.content.res.Configuration) +[68.2]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html +[68.3]: {{javadoc_uri}}/ContentBlocking.html#AT_CRYPTOMINING +[68.4]: {{javadoc_uri}}/ContentBlocking.html#AT_DEFAULT +[68.5]: {{javadoc_uri}}/ContentBlocking.html#AT_STRICT +[68.6]: {{javadoc_uri}}/ContentBlocking.html#CB_DEFAULT +[68.7]: {{javadoc_uri}}/ContentBlocking.html#CB_STRICT +[68.8]: {{javadoc_uri}}/GeckoSession.SessionState.html#fromString(java.lang.String) +[68.9]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setPreferredColorScheme(int) +[68.10]: {{javadoc_uri}}/RuntimeTelemetry.html#getSnapshots(boolean) +[68.11]: {{javadoc_uri}}/ContentBlocking.html#AT_FINGERPRINTING +[68.12]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html +[68.13]: {{javadoc_uri}}/GeckoSession.html +[68.16]: {{javadoc_uri}}/GeckoRuntimeSettings.Builder.html#configFilePath(java.lang.String) +[68.17]: {{javadoc_uri}}/GeckoRuntime.html#unregisterWebExtension(org.mozilla.geckoview.WebExtension) +[68.18]: {{javadoc_uri}}/WebExtension.html#setMessageDelegate(org.mozilla.geckoview.WebExtension.MessageDelegate,java.lang.String) +[68.19]: {{javadoc_uri}}/WebExtension.Port.html +[68.20]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoZoomEnabled(boolean) +[68.21]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setDoubleTapZoomingEnabled(boolean) +[68.22]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setGlMsaaLevel(int) +[68.23]: {{javadoc_uri}}/GeckoView.html#setVerticalClipping(int) +[68.24]: {{javadoc_uri}}/GeckoDisplay.html#setVerticalClipping(int) +[68.25]: {{javadoc_uri}}/StorageController.html +[68.26]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html#onRecordingStatusChanged(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.MediaDelegate.RecordingDevice[]) +[68.27]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html +[68.28]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.MediaSource.html +[68.29]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.html#onSessionStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.SessionState) +[68.30]: https://developer.android.com/reference/org/json/JSONObject +[68.31]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryItem.html +[68.32]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.HistoryList.html +[68.33]: {{javadoc_uri}}/GeckoSession.html#gotoHistoryIndex(int) +[68.34]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html#onHistoryStateChange(org.mozilla.geckoview.GeckoSession,org.mozilla.geckoview.GeckoSession.HistoryDelegate.HistoryList) +[68.35]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#PERMISSION_PERSISTENT_STORAGE + +## v67 +- Added [`setAutomaticFontSizeAdjustment`][67.23] to + [`GeckoRuntimeSettings`][67.3] for automatically adjusting font size settings + depending on the OS-level font size setting. +- Added [`setFontSizeFactor`][67.4] to [`GeckoRuntimeSettings`][67.3] for + setting a font size scaling factor, and for enabling font inflation for + non-mobile-friendly pages. +- Updated video autoplay API to reflect changes in Gecko. Instead of being a + per-video permission in the [`PermissionDelegate`][67.5], it is a [runtime + setting][67.6] that either allows or blocks autoplay videos. +- Change [`ContentBlocking.AT_AD`][67.7] and [`ContentBlocking.SB_ALL`][67.8] + values to mirror the actual constants they encompass. +- Added nested [`ContentBlocking`][67.9] runtime settings. +- Added [`RuntimeSettings`][67.10] base class to support nested settings. +- Added [`baseUri`][67.11] to [`ContentDelegate.ContextElement`][65.21] and + changed [`linkUri`][67.12] to absolute form. +- Added [`scrollBy`][67.13] and [`scrollTo`][67.14] to [`PanZoomController`][65.4]. +- Added [`GeckoSession.getDefaultUserAgent`][67.1] to expose the build-time + default user agent synchronously. +- Changed [`WebResponse.body`][67.24] from a [`ByteBuffer`][67.25] to an [`InputStream`][67.26]. Apps that want access + to the entire response body will now need to read the stream themselves. +- Added [`GeckoWebExecutor.FETCH_FLAGS_NO_REDIRECTS`][67.27], which will cause [`GeckoWebExecutor.fetch()`][67.28] to not + automatically follow [HTTP redirects][67.29] (e.g., 302). +- Moved [`GeckoVRManager`][67.2] into the org.mozilla.geckoview package. +- Initial WebExtension support. [`GeckoRuntime#registerWebExtension`][67.15] + allows embedders to register a local web extension. +- Added API to [`GeckoView`][70.5] to take screenshot of the visible page. Calling [`capturePixels`][67.16] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.17] of the current [`Surface`][67.18] contents, or an [`IllegalStateException`][67.19] if the [`GeckoSession`][65.9] is not ready to render content. +- Added API to capture a screenshot to [`GeckoDisplay`][67.20]. [`capturePixels`][67.21] returns a [`GeckoResult`][65.25] that completes to a [`Bitmap`][67.16] of the current [`Surface`][67.17] contents, or an [`IllegalStateException`][67.18] if the [`GeckoSession`][65.9] is not ready to render content. +- Add missing [`@Nullable`][66.2] annotation to return value for + [`GeckoSession.PromptDelegate.ChoiceCallback.onPopupResult()`][67.30] +- Added `default` implementations for all non-functional `interface`s. +- Added [`ContentDelegate.onWebAppManifest`][67.22], which will deliver the contents of a parsed + and validated Web App Manifest on pages that contain one. + +[67.1]: {{javadoc_uri}}/GeckoSession.html#getDefaultUserAgent() +[67.2]: {{javadoc_uri}}/GeckoVRManager.html +[67.3]: {{javadoc_uri}}/GeckoRuntimeSettings.html +[67.4]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setFontSizeFactor(float) +[67.5]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html +[67.6]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutoplayDefault(int) +[67.7]: {{javadoc_uri}}/ContentBlocking.html#AT_AD +[67.8]: {{javadoc_uri}}/ContentBlocking.html#SB_ALL +[67.9]: {{javadoc_uri}}/ContentBlocking.html +[67.10]: {{javadoc_uri}}/RuntimeSettings.html +[67.11]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#baseUri +[67.12]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html#linkUri +[67.13]: {{javadoc_uri}}/PanZoomController.html#scrollBy(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength) +[67.14]: {{javadoc_uri}}/PanZoomController.html#scrollTo(org.mozilla.geckoview.ScreenLength,org.mozilla.geckoview.ScreenLength) +[67.15]: {{javadoc_uri}}/GeckoRuntime.html#registerWebExtension(org.mozilla.geckoview.WebExtension) +[67.16]: {{javadoc_uri}}/GeckoView.html#capturePixels() +[67.17]: https://developer.android.com/reference/android/graphics/Bitmap +[67.18]: https://developer.android.com/reference/android/view/Surface +[67.19]: https://developer.android.com/reference/java/lang/IllegalStateException +[67.20]: {{javadoc_uri}}/GeckoDisplay.html +[67.21]: {{javadoc_uri}}/GeckoDisplay.html#capturePixels() +[67.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onWebAppManifest(org.mozilla.geckoview.GeckoSession,org.json.JSONObject) +[67.23]: {{javadoc_uri}}/GeckoRuntimeSettings.html#setAutomaticFontSizeAdjustment(boolean) +[67.24]: {{javadoc_uri}}/WebResponse.html#body +[67.25]: https://developer.android.com/reference/java/nio/ByteBuffer +[67.26]: https://developer.android.com/reference/java/io/InputStream +[67.27]: {{javadoc_uri}}/GeckoWebExecutor.html#FETCH_FLAGS_NO_REDIRECTS +[67.28]: {{javadoc_uri}}/GeckoWebExecutor.html#fetch(org.mozilla.geckoview.WebRequest,int) +[67.29]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Redirections +[67.30]: {{javadoc_uri}}/GeckoSession.PromptDelegate.ChoiceCallback.html + +## v66 +- Removed redundant field `trackingMode` from [`SecurityInformation`][66.6]. + Use `TrackingProtectionDelegate.onTrackerBlocked` for notification of blocked + elements during page load. +- Added [`@NonNull`][66.1] or [`@Nullable`][66.2] to all APIs. +- Added methods for each setting in [`GeckoSessionSettings`][66.3] +- Added [`GeckoSessionSettings`][66.4] for enabling desktop viewport. Desktop + viewport is no longer set by [`USER_AGENT_MODE_DESKTOP`][66.5] and must be set + separately. +- Added [`@UiThread`][65.6] to [`GeckoSession.releaseSession`][66.7] and + [`GeckoSession.setSession`][66.8] + +[66.1]: https://developer.android.com/reference/android/support/annotation/NonNull +[66.2]: https://developer.android.com/reference/android/support/annotation/Nullable +[66.3]: {{javadoc_uri}}/GeckoSessionSettings.html +[66.4]: {{javadoc_uri}}/GeckoSessionSettings.html +[66.5]: {{javadoc_uri}}/GeckoSessionSettings.html#USER_AGENT_MODE_DESKTOP +[66.6]: {{javadoc_uri}}/GeckoSession.ProgressDelegate.SecurityInformation.html +[66.7]: {{javadoc_uri}}/GeckoView.html#releaseSession() +[66.8]: {{javadoc_uri}}/GeckoView.html#setSession(org.mozilla.geckoview.GeckoSession) + +## v65 +- Added experimental ad-blocking category to `GeckoSession.TrackingProtectionDelegate`. +- Moved [`CompositorController`][65.1], [`DynamicToolbarAnimator`][65.2], + [`OverscrollEdgeEffect`][65.3], [`PanZoomController`][65.4] from + `org.mozilla.gecko.gfx` to [`org.mozilla.geckoview`][65.5] +- Added [`@UiThread`][65.6], [`@AnyThread`][65.7] annotations to all APIs +- Changed `GeckoRuntimeSettings#getLocale` to [`getLocales`][65.8] and related + APIs. +- Merged `org.mozilla.gecko.gfx.LayerSession` into [`GeckoSession`][65.9] +- Added [`GeckoSession.MediaDelegate`][65.10] and [`MediaElement`][65.11]. This + allow monitoring and control of web media elements (play, pause, seek, etc). +- Removed unused `access` parameter from + [`GeckoSession.PermissionDelegate#onContentPermissionRequest`][65.12] +- Added [`WebMessage`][65.13], [`WebRequest`][65.14], [`WebResponse`][65.15], + and [`GeckoWebExecutor`][65.16]. This exposes Gecko networking to apps. It + includes speculative connections, name resolution, and a Fetch-like HTTP API. +- Added [`GeckoSession.HistoryDelegate`][65.17]. This allows apps to implement + their own history storage system and provide visited link status. +- Added [`ContentDelegate#onFirstComposite`][65.18] to get first composite + callback after a compositor start. +- Changed `LoadRequest.isUserTriggered` to [`isRedirect`][65.19]. +- Added [`GeckoSession.LOAD_FLAGS_BYPASS_CLASSIFIER`][65.20] to bypass the URI + classifier. +- Added a `protected` empty constructor to all field-only classes so that apps + can mock these classes in tests. +- Added [`ContentDelegate.ContextElement`][65.21] to extend the information + passed to [`ContentDelegate#onContextMenu`][65.22]. Extended information + includes the element's title and alt attributes. +- Changed [`ContentDelegate.ContextElement`][65.21] `TYPE_` constants to public + access. +- Changed [`ContentDelegate.ContextElement`][65.21], + [`GeckoSession.FinderResult`][65.23] to non-final class. +- Update [`CrashReporter#sendCrashReport`][65.24] to return the crash ID as a + [`GeckoResult<String>`][65.25]. + +[65.1]: {{javadoc_uri}}/CompositorController.html +[65.2]: {{javadoc_uri}}/DynamicToolbarAnimator.html +[65.3]: {{javadoc_uri}}/OverscrollEdgeEffect.html +[65.4]: {{javadoc_uri}}/PanZoomController.html +[65.5]: {{javadoc_uri}}/package-summary.html +[65.6]: https://developer.android.com/reference/android/support/annotation/UiThread +[65.7]: https://developer.android.com/reference/android/support/annotation/AnyThread +[65.8]: {{javadoc_uri}}/GeckoRuntimeSettings.html#getLocales() +[65.9]: {{javadoc_uri}}/GeckoSession.html +[65.10]: {{javadoc_uri}}/GeckoSession.MediaDelegate.html +[65.11]: {{javadoc_uri}}/MediaElement.html +[65.12]: {{javadoc_uri}}/GeckoSession.PermissionDelegate.html#onContentPermissionRequest(org.mozilla.geckoview.GeckoSession,java.lang.String,int,org.mozilla.geckoview.GeckoSession.PermissionDelegate.Callback) +[65.13]: {{javadoc_uri}}/WebMessage.html +[65.14]: {{javadoc_uri}}/WebRequest.html +[65.15]: {{javadoc_uri}}/WebResponse.html +[65.16]: {{javadoc_uri}}/GeckoWebExecutor.html +[65.17]: {{javadoc_uri}}/GeckoSession.HistoryDelegate.html +[65.18]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onFirstComposite(org.mozilla.geckoview.GeckoSession) +[65.19]: {{javadoc_uri}}/GeckoSession.NavigationDelegate.LoadRequest.html#isRedirect +[65.20]: {{javadoc_uri}}/GeckoSession.html#LOAD_FLAGS_BYPASS_CLASSIFIER +[65.21]: {{javadoc_uri}}/GeckoSession.ContentDelegate.ContextElement.html +[65.22]: {{javadoc_uri}}/GeckoSession.ContentDelegate.html#onContextMenu(org.mozilla.geckoview.GeckoSession,int,int,org.mozilla.geckoview.GeckoSession.ContentDelegate.ContextElement) +[65.23]: {{javadoc_uri}}/GeckoSession.FinderResult.html +[65.24]: {{javadoc_uri}}/CrashReporter.html#sendCrashReport(android.content.Context,android.os.Bundle,java.lang.String) +[65.25]: {{javadoc_uri}}/GeckoResult.html + +[api-version]: a08a53cd6a57e5b698d1b74d82a7be789926754c diff --git a/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java new file mode 100644 index 0000000000..4394d27f72 --- /dev/null +++ b/mobile/android/geckoview/src/main/java/org/mozilla/geckoview/package-info.java @@ -0,0 +1,40 @@ +/* -*- 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/. */ + +/** + * This package contains the public interfaces for the library. + * + * <ul> + * <li>{@link org.mozilla.geckoview.GeckoRuntime} is the entry point for starting and initializing + * Gecko. You can use this to preload Gecko before you need to load a page or to configure + * features such as crash reporting. + * <li>{@link org.mozilla.geckoview.GeckoSession} is where most interesting work happens, such as + * loading pages. It relies on {@link org.mozilla.geckoview.GeckoRuntime} to talk to Gecko. + * <li>{@link org.mozilla.geckoview.GeckoView} is the embeddable {@link android.view.View}. This + * is the most common way of getting a {@link org.mozilla.geckoview.GeckoSession} onto the + * screen. + * </ul> + * + * <p><strong>Permissions</strong> + * + * <p>This library does not request any dangerous permissions in the manifest, though it's possible + * that some web features may require them. For instance, WebRTC video calls would need the {@link + * android.Manifest.permission#CAMERA} and {@link android.Manifest.permission#RECORD_AUDIO} + * permissions. Declaring these are at the application's discretion. If you want full web + * functionality, the following permissions should be declared: + * + * <ul> + * <li>{@link android.Manifest.permission#ACCESS_COARSE_LOCATION} + * <li>{@link android.Manifest.permission#ACCESS_FINE_LOCATION} + * <li>{@link android.Manifest.permission#READ_EXTERNAL_STORAGE} + * <li>{@link android.Manifest.permission#WRITE_EXTERNAL_STORAGE} + * <li>{@link android.Manifest.permission#CAMERA} + * <li>{@link android.Manifest.permission#RECORD_AUDIO} + * </ul> + * + * For a detailed change log of the API see: <a href="./doc-files/CHANGELOG" + * target="_blank">CHANGELOG</a>. + */ +package org.mozilla.geckoview; |