/* -*- 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);
                        }
                        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 */ float textScaleFactor() {
    return getSettings().getFontSizeFactor();
  }

  @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];
        }
      };
}
