/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type {
  DefaultCreateSessionResult,
  DriverData,
  ExternalDriver,
  InitialOpts,
  Orientation,
  RouteMatcher,
  SingularSessionData,
  StringRecord,
} from '@appium/types';
import {DEFAULT_ADB_PORT} from 'appium-adb';
import {AndroidDriver, utils} from 'appium-android-driver';
import {SETTINGS_HELPER_ID} from 'io.appium.settings';
import {BaseDriver, DeviceSettings} from 'appium/driver';
import {fs, mjpeg, util} from 'appium/support';
import {retryInterval} from 'asyncbox';
import B from 'bluebird';
import _ from 'lodash';
import os from 'node:os';
import path from 'node:path';
import {checkPortStatus, findAPortNotInUse} from 'portscanner';
import type {ExecError} from 'teen_process';
import UIAUTOMATOR2_CONSTRAINTS, {type Uiautomator2Constraints} from './constraints';
import {APKS_EXTENSION, APK_EXTENSION} from './extensions';
import {newMethodMap} from './method-map';
import { signApp } from './helpers';
import type { EmptyObject } from 'type-fest';
import type {
  Uiautomator2Settings,
  Uiautomator2DeviceDetails,
  Uiautomator2DriverCaps,
  Uiautomator2DriverOpts,
  Uiautomator2SessionCaps,
  Uiautomator2SessionInfo,
  Uiautomator2StartSessionOpts,
  W3CUiautomator2DriverCaps,
} from './types';
import {SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID, UiAutomator2Server} from './uiautomator2';
import {
  mobileGetActionHistory,
  mobileScheduleAction,
  mobileUnscheduleAction,
  performActions,
  releaseActions,
} from './commands/actions';
import {
  getAlertText,
  mobileAcceptAlert,
  mobileDismissAlert,
  postAcceptAlert,
  postDismissAlert,
} from './commands/alert';
import {
  mobileInstallMultipleApks,
} from './commands/app-management';
import {
  mobileGetBatteryInfo,
} from './commands/battery';
import {
  getClipboard,
  setClipboard,
} from './commands/clipboard';
import {
  active,
  getAttribute,
  elementEnabled,
  elementDisplayed,
  elementSelected,
  getName,
  getLocation,
  getSize,
  getElementRect,
  getElementScreenshot,
  getText,
  setValueImmediate,
  doSetElementValue,
  click,
  clear,
  mobileReplaceElementValue,
} from './commands/element';
import {
  doFindElementOrEls,
} from './commands/find';
import {
  mobileClickGesture,
  mobileDoubleClickGesture,
  mobileDragGesture,
  mobileFlingGesture,
  mobileLongClickGesture,
  mobilePinchCloseGesture,
  mobilePinchOpenGesture,
  mobileScroll,
  mobileScrollBackTo,
  mobileScrollGesture,
  mobileSwipeGesture,
} from './commands/gestures';
import {
  pressKeyCode,
  longPressKeyCode,
  mobilePressKey,
  mobileType,
  doSendKeys,
  keyevent,
} from './commands/keyboard';
import {
  getPageSource,
  getOrientation,
  setOrientation,
  openNotifications,
  suspendChromedriverProxy,
  mobileGetDeviceInfo,
} from './commands/misc';
import {
  setUrl,
  mobileDeepLink,
  back,
} from './commands/navigation';
import {
  mobileScreenshots,
  mobileViewportScreenshot,
  getScreenshot,
  getViewportScreenshot,
} from './commands/screenshot';
import {
  getStatusBarHeight,
  getDevicePixelRatio,
  getDisplayDensity,
  getViewPortRect,
  getWindowRect,
  getWindowSize,
  mobileViewPortRect,
} from './commands/viewport';
import { executeMethodMap } from './execute-method-map';

// The range of ports we can use on the system for communicating to the
// UiAutomator2 HTTP server on the device
const DEVICE_PORT_RANGE = [8200, 8299];

// The guard is needed to avoid dynamic system port allocation conflicts for
// parallel driver sessions
const DEVICE_PORT_ALLOCATION_GUARD = util.getLockFileGuard(
  path.resolve(os.tmpdir(), 'uia2_device_port_guard'),
  {timeout: 25, tryRecovery: true}
);

// This is the port that UiAutomator2 listens to on the device. We will forward
// one of the ports above on the system to this port on the device.
const DEVICE_PORT = 6790;
// This is the port that the UiAutomator2 MJPEG server listens to on the device.
// We will forward one of the ports above on the system to this port on the
// device.
const MJPEG_SERVER_DEVICE_PORT = 7810;

const LOCALHOST_IP4 = '127.0.0.1';

// NO_PROXY contains the paths that we never want to proxy to UiAutomator2 server.
// TODO:  Add the list of paths that we never want to proxy to UiAutomator2 server.
// TODO: Need to segregate the paths better way using regular expressions wherever applicable.
// (Not segregating right away because more paths to be added in the NO_PROXY list)
const NO_PROXY: RouteMatcher[] = [
  ['DELETE', new RegExp('^/session/[^/]+/actions')],
  ['GET', new RegExp('^/session/(?!.*/)')],
  ['GET', new RegExp('^/session/[^/]+/alert_[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/alert/[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/appium/[^/]+/current_activity')],
  ['GET', new RegExp('^/session/[^/]+/appium/[^/]+/current_package')],
  ['GET', new RegExp('^/session/[^/]+/appium/app/[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/appium/device/[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/appium/settings')],
  ['GET', new RegExp('^/session/[^/]+/context')],
  ['GET', new RegExp('^/session/[^/]+/contexts')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/attribute')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/displayed')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/enabled')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/location_in_view')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/name')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/screenshot')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/selected')],
  ['GET', new RegExp('^/session/[^/]+/ime/[^/]+')],
  ['GET', new RegExp('^/session/[^/]+/location')],
  ['GET', new RegExp('^/session/[^/]+/network_connection')],
  ['GET', new RegExp('^/session/[^/]+/screenshot')],
  ['GET', new RegExp('^/session/[^/]+/timeouts')],
  ['GET', new RegExp('^/session/[^/]+/url')],
  ['POST', new RegExp('^/session/[^/]+/[^/]+_alert$')],
  ['POST', new RegExp('^/session/[^/]+/actions')],
  ['POST', new RegExp('^/session/[^/]+/alert/[^/]+')],
  ['POST', new RegExp('^/session/[^/]+/app/[^/]')],
  ['POST', new RegExp('^/session/[^/]+/appium/[^/]+/start_activity')],
  ['POST', new RegExp('^/session/[^/]+/appium/app/[^/]+')],
  ['POST', new RegExp('^/session/[^/]+/appium/compare_images')],
  ['POST', new RegExp('^/session/[^/]+/appium/device/(?!set_clipboard)[^/]+')],
  ['POST', new RegExp('^/session/[^/]+/appium/element/[^/]+/replace_value')],
  ['POST', new RegExp('^/session/[^/]+/appium/element/[^/]+/value')],
  ['POST', new RegExp('^/session/[^/]+/appium/getPerformanceData')],
  ['POST', new RegExp('^/session/[^/]+/appium/performanceData/types')],
  ['POST', new RegExp('^/session/[^/]+/appium/settings')],
  ['POST', new RegExp('^/session/[^/]+/appium/execute_driver')],
  ['POST', new RegExp('^/session/[^/]+/appium/start_recording_screen')],
  ['POST', new RegExp('^/session/[^/]+/appium/stop_recording_screen')],
  ['POST', new RegExp('^/session/[^/]+/appium/.*event')],
  ['POST', new RegExp('^/session/[^/]+/context')],
  ['POST', new RegExp('^/session/[^/]+/element')],
  ['POST', new RegExp('^/session/[^/]+/ime/[^/]+')],
  ['POST', new RegExp('^/session/[^/]+/keys')],
  ['POST', new RegExp('^/session/[^/]+/location')],
  ['POST', new RegExp('^/session/[^/]+/network_connection')],
  ['POST', new RegExp('^/session/[^/]+/timeouts')],
  ['POST', new RegExp('^/session/[^/]+/url')],

  // MJSONWP commands
  ['GET', new RegExp('^/session/[^/]+/log/types')],
  ['POST', new RegExp('^/session/[^/]+/execute')],
  ['POST', new RegExp('^/session/[^/]+/execute_async')],
  ['POST', new RegExp('^/session/[^/]+/log')],
  // W3C commands
  // For Selenium v4 (W3C does not have this route)
  ['GET', new RegExp('^/session/[^/]+/se/log/types')],
  ['GET', new RegExp('^/session/[^/]+/window/rect')],
  ['POST', new RegExp('^/session/[^/]+/execute/async')],
  ['POST', new RegExp('^/session/[^/]+/execute/sync')],
  // For Selenium v4 (W3C does not have this route)
  ['POST', new RegExp('^/session/[^/]+/se/log')],
];

// This is a set of methods and paths that we never want to proxy to Chromedriver.
const CHROME_NO_PROXY: RouteMatcher[] = [
  ['GET', new RegExp('^/session/[^/]+/appium')],
  ['GET', new RegExp('^/session/[^/]+/context')],
  ['GET', new RegExp('^/session/[^/]+/element/[^/]+/rect')],
  ['GET', new RegExp('^/session/[^/]+/orientation')],
  ['POST', new RegExp('^/session/[^/]+/appium')],
  ['POST', new RegExp('^/session/[^/]+/context')],
  ['POST', new RegExp('^/session/[^/]+/orientation')],

  // this is needed to make the mobile: commands working in web context
  ['POST', new RegExp('^/session/[^/]+/execute$')],
  ['POST', new RegExp('^/session/[^/]+/execute/sync')],

  // MJSONWP commands
  ['GET', new RegExp('^/session/[^/]+/log/types$')],
  ['POST', new RegExp('^/session/[^/]+/log$')],
  // W3C commands
  // For Selenium v4 (W3C does not have this route)
  ['GET', new RegExp('^/session/[^/]+/se/log/types$')],
  // For Selenium v4 (W3C does not have this route)
  ['POST', new RegExp('^/session/[^/]+/se/log$')],
];

const MEMOIZED_FUNCTIONS = ['getStatusBarHeight', 'getDevicePixelRatio'] as const;

class AndroidUiautomator2Driver
  extends AndroidDriver
  implements
    ExternalDriver<
      Uiautomator2Constraints,
      string,
      StringRecord
    >
{
  static newMethodMap = newMethodMap;
  static executeMethodMap = executeMethodMap;

  uiautomator2: UiAutomator2Server;

  systemPort: number | undefined;

  _originalIme: string | null;

  mjpegStream?: mjpeg.MJpegStream;

  override caps: Uiautomator2DriverCaps;

  override opts: Uiautomator2DriverOpts;

  override desiredCapConstraints: Uiautomator2Constraints;

  constructor(opts: InitialOpts = {} as InitialOpts, shouldValidateCaps = true) {
    // `shell` overwrites adb.shell, so remove
    // @ts-expect-error FIXME: what is this?
    delete opts.shell;

    super(opts, shouldValidateCaps);

    this.locatorStrategies = [
      'xpath',
      'id',
      'class name',
      'accessibility id',
      'css selector',
      '-android uiautomator',
    ];
    this.desiredCapConstraints = _.cloneDeep(UIAUTOMATOR2_CONSTRAINTS);
    this.jwpProxyActive = false;
    this.jwpProxyAvoid = NO_PROXY;
    this._originalIme = null;

    this.settings = new DeviceSettings(
      {ignoreUnimportantViews: false, allowInvisibleElements: false},
      this.onSettingsUpdate.bind(this)
    );
    // handle webview mechanics from AndroidDriver
    this.sessionChromedrivers = {};

    this.caps = {} as Uiautomator2DriverCaps;
    this.opts = opts as Uiautomator2DriverOpts;
    // memoize functions here, so that they are done on a per-instance basis
    for (const fn of MEMOIZED_FUNCTIONS) {
      this[fn] = _.memoize(this[fn]) as any;
    }
  }

  override validateDesiredCaps(caps: any): caps is Uiautomator2DriverCaps {
    return super.validateDesiredCaps(caps);
  }

  async createSession(
    w3cCaps1: W3CUiautomator2DriverCaps,
    w3cCaps2?: W3CUiautomator2DriverCaps,
    w3cCaps3?: W3CUiautomator2DriverCaps,
    driverData?: DriverData[]
  ): Promise<any> {
    try {
      // TODO handle otherSessionData for multiple sessions
      const [sessionId, caps] = (await BaseDriver.prototype.createSession.call(
        this,
        w3cCaps1,
        w3cCaps2,
        w3cCaps3,
        driverData
      )) as DefaultCreateSessionResult<Uiautomator2Constraints>;

      const startSessionOpts: Uiautomator2StartSessionOpts = {
        ...caps,
        platform: 'LINUX',
        webStorageEnabled: false,
        takesScreenshot: true,
        javascriptEnabled: true,
        databaseEnabled: false,
        networkConnectionEnabled: true,
        locationContextEnabled: false,
        warnings: {},
        desired: caps,
      };

      const defaultOpts = {
        fullReset: false,
        autoLaunch: true,
        adbPort: DEFAULT_ADB_PORT,
        androidInstallTimeout: 90000,
      };
      _.defaults(this.opts, defaultOpts);

      this.opts.adbPort = this.opts.adbPort || DEFAULT_ADB_PORT;
      // get device udid for this session
      const {udid, emPort} = await this.getDeviceInfoFromCaps();
      this.opts.udid = udid;
      // @ts-expect-error do not put random stuff on opts
      this.opts.emPort = emPort;
      // now that we know our java version and device info, we can create our
      // ADB instance
      this.adb = await this.createADB();

      if (this.isChromeSession) {
        this.log.info(`We're going to run a Chrome-based session`);
        const {pkg, activity: defaultActivity} = utils.getChromePkg(this.opts.browserName!);
        let activity: string = defaultActivity;
        if (await this.adb.getApiLevel() >= 24) {
          try {
            activity = await this.adb.resolveLaunchableActivity(pkg);
          } catch (e) {
            this.log.warn(`Using the default ${pkg} activity ${activity}. Original error: ${e.message}`);
          }
        }
        this.opts.appPackage = this.caps.appPackage = pkg;
        this.opts.appActivity = this.caps.appActivity = activity;
        this.log.info(`Chrome-type package and activity are ${pkg} and ${activity}`);
      }

      if (this.opts.app) {
        // find and copy, or download and unzip an app url or path
        this.opts.app = await this.helpers.configureApp(this.opts.app, [
          APK_EXTENSION,
          APKS_EXTENSION,
        ]);
        await this.checkAppPresent();
      } else if (this.opts.appPackage) {
        // the app isn't an actual app file but rather something we want to
        // assume is on the device and just launch via the appPackage
        this.log.info(`Starting '${this.opts.appPackage}' directly on the device`);
      } else {
        this.log.info(
          `Neither 'app' nor 'appPackage' was set. Starting UiAutomator2 ` +
            'without the target application'
        );
      }

      const result = await this.startUiAutomator2Session(startSessionOpts);

      if (this.opts.mjpegScreenshotUrl) {
        this.log.info(`Starting MJPEG stream reading URL: '${this.opts.mjpegScreenshotUrl}'`);
        this.mjpegStream = new mjpeg.MJpegStream(this.opts.mjpegScreenshotUrl);
        await this.mjpegStream.start();
      }
      return [sessionId, result];
    } catch (e) {
      await this.deleteSession();
      throw e;
    }
  }

  async getDeviceDetails(): Promise<Uiautomator2DeviceDetails> {
    const [
      pixelRatio,
      statBarHeight,
      viewportRect,
      {apiVersion, platformVersion, manufacturer, model, realDisplaySize, displayDensity},
    ] = await B.all([
      this.getDevicePixelRatio(),
      this.getStatusBarHeight(),
      this.getViewPortRect(),
      this.mobileGetDeviceInfo(),
    ]);

    return {
      pixelRatio,
      statBarHeight,
      viewportRect,
      deviceApiLevel: _.parseInt(apiVersion),
      platformVersion,
      deviceManufacturer: manufacturer,
      deviceModel: model,
      deviceScreenSize: realDisplaySize,
      deviceScreenDensity: displayDensity,
    };
  }

  override get driverData() {
    // TODO fill out resource info here
    return {};
  }

  override async getSession(): Promise<SingularSessionData<Uiautomator2Constraints>> {
    const sessionData = await BaseDriver.prototype.getSession.call(this);
    this.log.debug('Getting session details from server to mix in');
    const uia2Data = (await this.uiautomator2!.jwproxy.command('/', 'GET', {})) as any;
    return {...sessionData, ...uia2Data};
  }

  async allocateSystemPort() {
    const forwardPort = async (localPort: number) => {
      this.log.debug(
        `Forwarding UiAutomator2 Server port ${DEVICE_PORT} to local port ${localPort}`
      );
      if ((await checkPortStatus(localPort, LOCALHOST_IP4)) === 'open') {
        throw this.log.errorWithException(
          `UiAutomator2 Server cannot start because the local port #${localPort} is busy. ` +
            `Make sure the port you provide via 'systemPort' capability is not occupied. ` +
            `This situation might often be a result of an inaccurate sessions management, e.g. ` +
            `old automation sessions on the same device must always be closed before starting new ones.`
        );
      }
      await this.adb!.forwardPort(localPort, DEVICE_PORT);
    };

    if (this.opts.systemPort) {
      this.systemPort = this.opts.systemPort;
      return await forwardPort(this.systemPort);
    }

    await DEVICE_PORT_ALLOCATION_GUARD(async () => {
      const [startPort, endPort] = DEVICE_PORT_RANGE;
      try {
        this.systemPort = await findAPortNotInUse(startPort, endPort);
      } catch {
        throw this.log.errorWithException(
          `Cannot find any free port in range ${startPort}..${endPort}}. ` +
            `Please set the available port number by providing the systemPort capability or ` +
            `double check the processes that are locking ports within this range and terminate ` +
            `these which are not needed anymore`
        );
      }
      await forwardPort(this.systemPort);
    });
  }

  async releaseSystemPort() {
    if (!this.systemPort || !this.adb) {
      return;
    }

    if (this.opts.systemPort) {
      // We assume if the systemPort is provided manually then it must be unique,
      // so there is no need for the explicit synchronization
      await this.adb.removePortForward(this.systemPort);
    } else {
      await DEVICE_PORT_ALLOCATION_GUARD(
        async () => await this.adb!.removePortForward(this.systemPort!)
      );
    }
  }

  async allocateMjpegServerPort() {
    if (this.opts.mjpegServerPort) {
      this.log.debug(
        `MJPEG broadcasting requested, forwarding MJPEG server port ${MJPEG_SERVER_DEVICE_PORT} ` +
          `to local port ${this.opts.mjpegServerPort}`
      );
      await this.adb!.forwardPort(this.opts.mjpegServerPort, MJPEG_SERVER_DEVICE_PORT);
    }
  }

  async releaseMjpegServerPort() {
    if (this.opts.mjpegServerPort) {
      await this.adb!.removePortForward(this.opts.mjpegServerPort);
    }
  }

  async performSessionPreExecSetup(): Promise<StringRecord|undefined> {
    const apiLevel = await this.adb.getApiLevel();
    if (apiLevel < 21) {
      throw this.log.errorWithException(
        'UIAutomator2 is only supported since Android 5.0 (Lollipop). ' +
          'You could still use other supported backends in order to automate older Android versions.'
      );
    }

    const preflightPromises: Promise<any>[] = [];
    if (apiLevel >= 28) {
      // Android P
      preflightPromises.push((async () => {
        this.log.info('Relaxing hidden api policy');
        try {
          await this.adb.setHiddenApiPolicy('1', !!this.opts.ignoreHiddenApiPolicyError);
        } catch (err) {
          throw this.log.errorWithException(
            'Hidden API policy (https://developer.android.com/guide/app-compatibility/restrictions-non-sdk-interfaces) cannot be enabled. ' +
              'This might be happening because the device under test is not configured properly. ' +
              'Please check https://github.com/appium/appium/issues/13802 for more details. ' +
              'You could also set the "appium:ignoreHiddenApiPolicyError" capability to true in order to ' +
              'ignore this error, which might later lead to unexpected crashes or behavior of ' +
              `the automation server. Original error: ${err.message}`
          );
        }
      })());
    }
    if (util.hasValue(this.opts.gpsEnabled)) {
      preflightPromises.push((async () => {
        this.log.info(
          `Trying to ${this.opts.gpsEnabled ? 'enable' : 'disable'} gps location provider`
        );
        await this.adb.toggleGPSLocationProvider(Boolean(this.opts.gpsEnabled));
      })());
    }
    if (this.opts.hideKeyboard) {
      preflightPromises.push((async () => {
        this._originalIme = await this.adb.defaultIME();
      })());
    }
    let appInfo;
    preflightPromises.push((async () => {
      // get appPackage et al from manifest if necessary
      appInfo = await this.getLaunchInfo();
    })());
    // start settings app, set the language/locale, start logcat etc...
    preflightPromises.push(this.initDevice());

    await B.all(preflightPromises);

    this.opts = {...this.opts, ...(appInfo ?? {})};
    return appInfo;
  }

  async performSessionExecution(capsWithSessionInfo: StringRecord): Promise<void> {
    await B.all([
      // Prepare the device by forwarding the UiAutomator2 port
      // This call mutates this.systemPort if it is not set explicitly
      this.allocateSystemPort(),
      // Prepare the device by forwarding the UiAutomator2 MJPEG server port (if
      // applicable)
      this.allocateMjpegServerPort(),
    ]);

    const [uiautomator2,] = await B.all([
      // set up the modified UiAutomator2 server etc
      this.initUiAutomator2Server(),
      (async () => {
        // Should be after installing io.appium.settings
        if (this.opts.disableWindowAnimation && await this.adb.getApiLevel() < 26) {
          // API level 26 is Android 8.0.
          // Granting android.permission.SET_ANIMATION_SCALE is necessary to handle animations under API level 26
          // Read https://github.com/appium/appium/pull/11640#issuecomment-438260477
          // `--no-window-animation` works over Android 8 to disable all of animations
          if (await this.adb.isAnimationOn()) {
            this.log.info('Disabling animation via io.appium.settings');
            await this.settingsApp.setAnimationState(false);
            this._wasWindowAnimationDisabled = true;
          } else {
            this.log.info('Window animation is already disabled');
          }
        }
      })(),
      // set up app under test
      // prepare our actual AUT, get it on the device, etc...
      this.initAUT(),
    ]);

    // launch UiAutomator2 and wait till its online and we have a session
    await uiautomator2.startSession(capsWithSessionInfo);
    // now that everything has started successfully, turn on proxying so all
    // subsequent session requests go straight to/from uiautomator2
    this.jwpProxyActive = true;
  }

  async performSessionPostExecSetup(): Promise<void> {
    // Unlock the device after the session is started.
    if (!this.opts.skipUnlock) {
      // unlock the device to prepare it for testing
      await this.unlock();
    } else {
      this.log.debug(`'skipUnlock' capability set, so skipping device unlock`);
    }

    if (this.isChromeSession) {
      // start a chromedriver session
      await this.startChromeSession();
    } else if (this.opts.autoLaunch && this.opts.appPackage) {
      await this.ensureAppStarts();
    }

    // if the initial orientation is requested, set it
    if (util.hasValue(this.opts.orientation)) {
      this.log.debug(`Setting initial orientation to '${this.opts.orientation}'`);
      await this.setOrientation(this.opts.orientation as Orientation);
    }

    // if we want to immediately get into a webview, set our context
    // appropriately
    if (this.opts.autoWebview) {
      const viewName = this.defaultWebviewName();
      const timeout = this.opts.autoWebviewTimeout || 2000;
      this.log.info(`Setting auto webview to context '${viewName}' with timeout ${timeout}ms`);
      await retryInterval(timeout / 500, 500, this.setContext.bind(this), viewName);
    }

    // We would like to notify about the initial context setting
    if (await this.getCurrentContext() === this.defaultContextName()) {
      await this.notifyBiDiContextChange();
    }
  }

  async startUiAutomator2Session(
    caps: Uiautomator2StartSessionOpts
  ): Promise<Uiautomator2SessionCaps> {
    const appInfo = await this.performSessionPreExecSetup();
    // set actual device name, udid, platform version, screen size, screen density, model and manufacturer details
    const sessionInfo: Uiautomator2SessionInfo = {
      deviceName: this.adb.curDeviceId!,
      deviceUDID: this.opts.udid!,
    };
    const capsWithSessionInfo = {
      ...caps,
      ...sessionInfo,
    };
    // Adding AUT info in the capabilities if it does not exist in caps
    if (appInfo) {
      for (const capName of ['appPackage', 'appActivity']) {
        if (!capsWithSessionInfo[capName] && appInfo[capName]) {
          capsWithSessionInfo[capName] = appInfo[capName];
        }
      }
    }

    await this.performSessionExecution(capsWithSessionInfo);

    const deviceInfoPromise: Promise<Uiautomator2DeviceDetails|EmptyObject> = (async () => {
      try {
        return await this.getDeviceDetails();
      } catch (e) {
        this.log.warn(`Cannot fetch device details. Original error: ${e.message}`);
        return {};
      }
    })();

    await this.performSessionPostExecSetup();

    return {...capsWithSessionInfo, ...(await deviceInfoPromise)};
  }

  async initUiAutomator2Server() {
    // broken out for readability
    const uiautomator2Opts = {
      // @ts-expect-error FIXME: maybe `address` instead of `host`?
      host: this.opts.remoteAdbHost || this.opts.host || LOCALHOST_IP4,
      systemPort: this.systemPort as number,
      devicePort: DEVICE_PORT,
      adb: this.adb,
      tmpDir: this.opts.tmpDir as string,
      disableWindowAnimation: !!this.opts.disableWindowAnimation,
      disableSuppressAccessibilityService: this.opts.disableSuppressAccessibilityService,
      readTimeout: this.opts.uiautomator2ServerReadTimeout,
      basePath: this.basePath,
    };
    // now that we have package and activity, we can create an instance of
    // uiautomator2 with the appropriate options
    this.uiautomator2 = new UiAutomator2Server(this.log, uiautomator2Opts);
    this.proxyReqRes = this.uiautomator2.proxyReqRes.bind(this.uiautomator2);
    this.proxyCommand = this.uiautomator2.proxyCommand.bind(
      this.uiautomator2
    ) as typeof this.proxyCommand;

    if (this.opts.skipServerInstallation) {
      this.log.info(`'skipServerInstallation' is set. Skipping UIAutomator2 server installation.`);
    } else {
      await this.uiautomator2.installServerApk(this.opts.uiautomator2ServerInstallTimeout);
      try {
        await this.adb!.addToDeviceIdleWhitelist(
          SETTINGS_HELPER_ID,
          SERVER_PACKAGE_ID,
          SERVER_TEST_PACKAGE_ID
        );
      } catch (e) {
        const err = e as ExecError;
        this.log.warn(
          `Cannot add server packages to the Doze whitelist. Original error: ` +
            (err.stderr || err.message)
        );
      }
    }

    return this.uiautomator2;
  }

  async initAUT() {
    // Uninstall any uninstallOtherPackages which were specified in caps
    if (this.opts.uninstallOtherPackages) {
      await this.uninstallOtherPackages(
        utils.parseArray(this.opts.uninstallOtherPackages),
        [SETTINGS_HELPER_ID, SERVER_PACKAGE_ID, SERVER_TEST_PACKAGE_ID]
      );
    }

    // Install any "otherApps" that were specified in caps
    if (this.opts.otherApps) {
      let otherApps;
      try {
        otherApps = utils.parseArray(this.opts.otherApps);
      } catch (e) {
        throw this.log.errorWithException(
          `Could not parse "otherApps" capability: ${(e as Error).message}`
        );
      }
      otherApps = await B.all(
        otherApps.map((app) => this.helpers.configureApp(app, [APK_EXTENSION, APKS_EXTENSION]))
      );
      await this.installOtherApks(otherApps);
    }

    if (this.opts.app) {
      if (
        (this.opts.noReset && !(await this.adb!.isAppInstalled(this.opts.appPackage!))) ||
        !this.opts.noReset
      ) {
        if (
          !this.opts.noSign &&
          !(await this.adb!.checkApkCert(this.opts.app, this.opts.appPackage!, {
            requireDefaultCert: false,
          }))
        ) {
          await signApp(this.adb!, this.opts.app);
        }
        if (!this.opts.skipUninstall) {
          await this.adb!.uninstallApk(this.opts.appPackage!);
        }
        await this.installAUT();
      } else {
        this.log.debug(
          'noReset has been requested and the app is already installed. Doing nothing'
        );
      }
    } else {
      if (this.opts.fullReset) {
        throw this.log.errorWithException(
          'Full reset requires an app capability, use fastReset if app is not provided'
        );
      }
      this.log.debug('No app capability. Assuming it is already on the device');
      if (this.opts.fastReset && this.opts.appPackage) {
        await this.resetAUT();
      }
    }
  }

  async ensureAppStarts() {
    // make sure we have an activity and package to wait for
    const appWaitPackage = this.opts.appWaitPackage || this.opts.appPackage;
    const appWaitActivity = this.opts.appWaitActivity || this.opts.appActivity;
    this.log.info(
      `Starting '${this.opts.appPackage}/${this.opts.appActivity}' ` +
        `and waiting for '${appWaitPackage}/${appWaitActivity}'`
    );

    if (
      this.opts.noReset &&
      !this.opts.forceAppLaunch &&
      (await this.adb!.processExists(this.opts.appPackage!))
    ) {
      this.log.info(
        `'${this.opts.appPackage}' is already running and noReset is enabled. ` +
          `Set forceAppLaunch capability to true if the app must be forcefully restarted on session startup.`
      );
      return;
    }
    await this.adb!.startApp({
      pkg: this.opts.appPackage!,
      activity: this.opts.appActivity,
      action: this.opts.intentAction || 'android.intent.action.MAIN',
      category: this.opts.intentCategory || 'android.intent.category.LAUNCHER',
      flags: this.opts.intentFlags || '0x10200000', // FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
      waitPkg: this.opts.appWaitPackage,
      waitActivity: this.opts.appWaitActivity,
      waitForLaunch: this.opts.appWaitForLaunch,
      waitDuration: this.opts.appWaitDuration,
      optionalIntentArguments: this.opts.optionalIntentArguments,
      stopApp: this.opts.forceAppLaunch || !this.opts.dontStopAppOnReset,
      retry: true,
      user: this.opts.userProfile,
    });
  }

  async deleteSession() {
    this.log.debug('Deleting UiAutomator2 session');

    const screenRecordingStopTasks = [
      async () => {
        if (!_.isEmpty(this._screenRecordingProperties)) {
          await this.stopRecordingScreen();
        }
      },
      async () => {
        if (await this.mobileIsMediaProjectionRecordingRunning()) {
          await this.mobileStopMediaProjectionRecording();
        }
      },
      async () => {
        if (!_.isEmpty(this._screenStreamingProps)) {
          await this.mobileStopScreenStreaming();
        }
      },
    ];

    try {
      await this.stopChromedriverProxies();
    } catch (err) {
      this.log.warn(`Unable to stop ChromeDriver proxies: ${(err as Error).message}`);
    }

    if (this.jwpProxyActive) {
      try {
        await this.uiautomator2.deleteSession();
      } catch (err) {
        this.log.warn(`Unable to proxy deleteSession to UiAutomator2: ${(err as Error).message}`);
      }
      this.jwpProxyActive = false;
    }

    if (this.adb) {
      await B.all(
        screenRecordingStopTasks.map((task) => {
          (async () => {
            try {
              await task();
            } catch {}
          })();
        })
      );

      if (this.opts.appPackage) {
        if (
          !this.isChromeSession &&
          ((!this.opts.dontStopAppOnReset && !this.opts.noReset) ||
            (this.opts.noReset && this.opts.shouldTerminateApp))
        ) {
          try {
            await this.adb.forceStop(this.opts.appPackage);
          } catch (err) {
            this.log.warn(`Unable to force stop app: ${(err as Error).message}`);
          }
        }
        if (this.opts.fullReset && !this.opts.skipUninstall) {
          this.log.debug(
            `Capability 'fullReset' set to 'true', Uninstalling '${this.opts.appPackage}'`
          );
          try {
            await this.adb.uninstallApk(this.opts.appPackage);
          } catch (err) {
            this.log.warn(`Unable to uninstall app: ${(err as Error).message}`);
          }
        }
      }
      // This value can be true if test target device is <= 26
      if (this._wasWindowAnimationDisabled) {
        this.log.info('Restoring window animation state');
        await this.settingsApp.setAnimationState(true);
      }
      if (this._originalIme) {
        try {
          await this.adb.setIME(this._originalIme);
        } catch (e) {
          this.log.warn(`Cannot restore the original IME: ${e.message}`);
        }
      }
      try {
        await this.releaseSystemPort();
      } catch (error) {
        this.log.warn(`Unable to remove system port forward: ${(error as Error).message}`);
        // Ignore, this block will also be called when we fall in catch block
        // and before even port forward.
      }
      try {
        await this.releaseMjpegServerPort();
      } catch (error) {
        this.log.warn(`Unable to remove MJPEG server port forward: ${(error as Error).message}`);
        // Ignore, this block will also be called when we fall in catch block
        // and before even port forward.
      }

      if ((await this.adb.getApiLevel()) >= 28) {
        // Android P
        this.log.info('Restoring hidden api policy to the device default configuration');
        await this.adb.setDefaultHiddenApiPolicy(!!this.opts.ignoreHiddenApiPolicyError);
      }
    }
    if (this.mjpegStream) {
      this.log.info('Closing MJPEG stream');
      this.mjpegStream.stop();
    }
    await super.deleteSession();
  }

  async checkAppPresent() {
    this.log.debug('Checking whether app is actually present');
    if (!this.opts.app || !(await fs.exists(this.opts.app))) {
      throw this.log.errorWithException(`Could not find app apk at '${this.opts.app}'`);
    }
  }

  async onSettingsUpdate() {
    // intentionally do nothing here, since commands.updateSettings proxies
    // settings to the uiauto2 server already
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  proxyActive(sessionId: string): boolean {
    // we always have an active proxy to the UiAutomator2 server
    return true;
  }

  // eslint-disable-next-line @typescript-eslint/no-unused-vars
  canProxy(sessionId: string): boolean {
    // we can always proxy to the uiautomator2 server
    return true;
  }

  getProxyAvoidList(): RouteMatcher[] {
    // we are maintaining two sets of NO_PROXY lists, one for chromedriver(CHROME_NO_PROXY)
    // and one for uiautomator2(NO_PROXY), based on current context will return related NO_PROXY list
    if (util.hasValue(this.chromedriver)) {
      // if the current context is webview(chromedriver), then return CHROME_NO_PROXY list
      this.jwpProxyAvoid = CHROME_NO_PROXY;
    } else {
      this.jwpProxyAvoid = NO_PROXY;
    }
    if (this.opts.nativeWebScreenshot) {
      this.jwpProxyAvoid = [
        ...this.jwpProxyAvoid,
        ['GET', new RegExp('^/session/[^/]+/screenshot')],
      ];
    }

    return this.jwpProxyAvoid;
  }

  async updateSettings(settings: Uiautomator2Settings) {
    await this.settings.update(settings);
    await this.uiautomator2!.jwproxy.command('/appium/settings', 'POST', {settings});
  }

  async getSettings() {
    const driverSettings = this.settings.getSettings();
    const serverSettings = (await this.uiautomator2!.jwproxy.command(
      '/appium/settings',
      'GET'
    )) as Partial<Uiautomator2Settings>;
    return {...driverSettings, ...serverSettings} as any;
  }

  mobileGetActionHistory = mobileGetActionHistory;
  mobileScheduleAction = mobileScheduleAction;
  mobileUnscheduleAction = mobileUnscheduleAction;
  performActions = performActions;
  releaseActions = releaseActions;

  getAlertText = getAlertText;
  mobileAcceptAlert = mobileAcceptAlert;
  mobileDismissAlert = mobileDismissAlert;
  postAcceptAlert = postAcceptAlert;
  postDismissAlert = postDismissAlert;

  mobileInstallMultipleApks = mobileInstallMultipleApks;

  mobileGetBatteryInfo = mobileGetBatteryInfo;

  active = active;
  getAttribute = getAttribute;
  elementEnabled = elementEnabled;
  elementDisplayed = elementDisplayed;
  elementSelected = elementSelected;
  getName = getName;
  getLocation = getLocation;
  getSize = getSize;
  getElementRect = getElementRect;
  getElementScreenshot = getElementScreenshot;
  getText = getText;
  setValueImmediate = setValueImmediate;
  doSetElementValue = doSetElementValue;
  click = click;
  clear = clear;
  mobileReplaceElementValue = mobileReplaceElementValue;

  doFindElementOrEls = doFindElementOrEls;

  mobileClickGesture = mobileClickGesture;
  mobileDoubleClickGesture = mobileDoubleClickGesture;
  mobileDragGesture = mobileDragGesture;
  mobileFlingGesture = mobileFlingGesture;
  mobileLongClickGesture = mobileLongClickGesture;
  mobilePinchCloseGesture = mobilePinchCloseGesture;
  mobilePinchOpenGesture = mobilePinchOpenGesture;
  mobileScroll = mobileScroll;
  mobileScrollBackTo = mobileScrollBackTo;
  mobileScrollGesture = mobileScrollGesture;
  mobileSwipeGesture = mobileSwipeGesture;

  pressKeyCode = pressKeyCode;
  longPressKeyCode = longPressKeyCode;
  mobilePressKey = mobilePressKey;
  mobileType = mobileType;
  doSendKeys = doSendKeys;
  keyevent = keyevent;

  getPageSource = getPageSource;
  getOrientation = getOrientation;
  setOrientation = setOrientation;
  openNotifications = openNotifications;
  suspendChromedriverProxy = suspendChromedriverProxy as any;
  mobileGetDeviceInfo = mobileGetDeviceInfo;

  getClipboard = getClipboard;
  setClipboard = setClipboard;

  setUrl = setUrl;
  mobileDeepLink = mobileDeepLink;
  back = back;

  mobileScreenshots = mobileScreenshots;
  mobileViewportScreenshot = mobileViewportScreenshot;
  getScreenshot = getScreenshot;
  getViewportScreenshot = getViewportScreenshot;

  getStatusBarHeight = getStatusBarHeight;
  getDevicePixelRatio = getDevicePixelRatio;
  getDisplayDensity = getDisplayDensity;
  getViewPortRect = getViewPortRect;
  getWindowRect = getWindowRect;
  getWindowSize = getWindowSize;
  mobileViewPortRect = mobileViewPortRect;
}

export {AndroidUiautomator2Driver};
