// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

import 'package:meta/meta.dart';
import 'package:process/process.dart';
import 'package:vm_service/vm_service.dart';

import '../application_package.dart';
import '../base/common.dart' show throwToolExit;
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../convert.dart';
import '../device.dart';
import '../device_port_forwarder.dart';
import '../device_vm_service_discovery_for_attach.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import '../vmservice.dart';
import 'android_builder.dart';
import 'android_console.dart';
import 'android_engine_cli_flags.dart';
import 'android_sdk.dart';
import 'application_package.dart';
import 'gradle_utils.dart' as gradle_utils;

/// Whether the [AndroidDevice] is believed to be a physical device or an emulator.
enum HardwareType { emulator, physical }

/// Map to help our `isLocalEmulator` detection.
///
/// See [AndroidDevice] for more explanation of why this is needed.
const kKnownHardware = <String, HardwareType>{
  'goldfish': HardwareType.emulator,
  'qcom': HardwareType.physical,
  'ranchu': HardwareType.emulator,
  'samsungexynos7420': HardwareType.physical,
  'samsungexynos7580': HardwareType.physical,
  'samsungexynos7870': HardwareType.physical,
  'samsungexynos7880': HardwareType.physical,
  'samsungexynos8890': HardwareType.physical,
  'samsungexynos8895': HardwareType.physical,
  'samsungexynos9810': HardwareType.physical,
  'samsungexynos7570': HardwareType.physical,
};

/// A physical Android device or emulator.
///
/// While [isLocalEmulator] attempts to distinguish between the device categories,
/// this is a best effort process and not a guarantee; certain physical devices
/// identify as emulators. These device identifiers may be added to the [kKnownHardware]
/// map to specify that they are actually physical devices.
class AndroidDevice extends Device {
  AndroidDevice(
    super.id, {
    this.productID,
    required this.modelID,
    this.deviceCodeName,
    required super.logger,
    required ProcessManager processManager,
    required this._platform,
    required this._androidSdk,
    required this._fileSystem,
    this._androidConsoleSocketFactory = kAndroidConsoleSocketFactory,
  }) : _logger = logger,
       _processManager = processManager,
       _processUtils = ProcessUtils(logger: logger, processManager: processManager),
       super(category: Category.mobile, platformType: PlatformType.android, ephemeral: true);

  final Logger _logger;
  final ProcessManager _processManager;
  final AndroidSdk _androidSdk;
  final Platform _platform;
  final FileSystem _fileSystem;
  final ProcessUtils _processUtils;
  final AndroidConsoleSocketFactory _androidConsoleSocketFactory;

  final String? productID;
  final String modelID;
  final String? deviceCodeName;

  @override
  // Wirelessly paired Android devices should have `adb-tls-connect` in the id.
  // Source: https://android.googlesource.com/platform/packages/modules/adb/+/f4ba8d73079b99532069dbe888a58167b8723d6c/adb_mdns.h#30
  DeviceConnectionInterface get connectionInterface => id.contains('adb-tls-connect')
      ? DeviceConnectionInterface.wireless
      : DeviceConnectionInterface.attached;

  late final Future<Map<String, String>> _properties = () async {
    var properties = <String, String>{};

    final List<String> propCommand = adbCommandForDevice(<String>['shell', 'getprop']);
    _logger.printTrace(propCommand.join(' '));

    try {
      // We pass an encoding of latin1 so that we don't try and interpret the
      // `adb shell getprop` result as UTF8.
      final ProcessResult result = await _processManager.run(
        propCommand,
        stdoutEncoding: latin1,
        stderrEncoding: latin1,
      );
      if (result.exitCode == 0 || _allowHeapCorruptionOnWindows(result.exitCode, _platform)) {
        properties = parseAdbDeviceProperties(result.stdout as String);
      } else {
        _logger.printError('Error ${result.exitCode} retrieving device properties for $name:');
        _logger.printError(result.stderr as String);
      }
    } on ProcessException catch (error) {
      _logger.printError('Error retrieving device properties for $name: $error');
    }
    return properties;
  }();

  Future<String?> _getProperty(String name) async {
    return (await _properties)[name];
  }

  @override
  late final Future<bool> isLocalEmulator = () async {
    final String? hardware = await _getProperty('ro.hardware');
    _logger.printTrace('ro.hardware = $hardware');
    if (kKnownHardware.containsKey(hardware)) {
      // Look for known hardware models.
      return kKnownHardware[hardware] == HardwareType.emulator;
    }
    // Fall back to a best-effort heuristic-based approach.
    final String? characteristics = await _getProperty('ro.build.characteristics');
    _logger.printTrace('ro.build.characteristics = $characteristics');
    return characteristics != null && characteristics.contains('emulator');
  }();

  /// The unique identifier for the emulator that corresponds to this device, or
  /// null if it is not an emulator.
  ///
  /// The ID returned matches that in the output of `flutter emulators`. Fetching
  /// this name may require connecting to the device and if an error occurs null
  /// will be returned.
  @override
  Future<String?> get emulatorId async {
    if (!(await isLocalEmulator)) {
      return null;
    }

    // Emulators always have IDs in the format emulator-(port) where port is the
    // Android Console port number.
    final emulatorPortRegex = RegExp(r'emulator-(\d+)');

    final Match? portMatch = emulatorPortRegex.firstMatch(id);
    if (portMatch == null || portMatch.groupCount < 1) {
      return null;
    }

    const host = 'localhost';
    final int port = int.parse(portMatch.group(1)!);
    _logger.printTrace('Fetching avd name for $name via Android console on $host:$port');

    try {
      final Socket socket = await _androidConsoleSocketFactory(host, port);
      final console = AndroidConsole(socket);

      try {
        await console.connect().timeout(
          const Duration(seconds: 2),
          onTimeout: () => throw TimeoutException('Connection timed out'),
        );

        return await console.getAvdName().timeout(
          const Duration(seconds: 2),
          onTimeout: () => throw TimeoutException('"avd name" timed out'),
        );
      } finally {
        console.destroy();
      }
    } on Exception catch (e) {
      _logger.printTrace('Failed to fetch avd name for emulator at $host:$port: $e');
      // If we fail to connect to the device, we should not fail so just return
      // an empty name. This data is best-effort.
      return null;
    }
  }

  @override
  late final Future<TargetPlatform> targetPlatform = () async {
    return switch (await cpuArch) {
      CpuArch.arm64 => TargetPlatform.android_arm64,
      CpuArch.armv7 => TargetPlatform.android_arm,
      CpuArch.x64 => TargetPlatform.android_x64,
      CpuArch.x86 || CpuArch.riscv64 || CpuArch.unknown => TargetPlatform.unsupported,
    };
  }();

  @override
  late final Future<CpuArch> cpuArch = () async {
    // http://developer.android.com/ndk/guides/abis.html (x86, armeabi-v7a, ...)
    final String? abi = await _getProperty('ro.product.cpu.abi');
    switch (abi) {
      case 'arm64-v8a':
        // Perform additional verification for 64 bit ABI. Some devices,
        // like the Kindle Fire 8, misreport the abilist. We might not
        // be able to retrieve this property, in which case we fall back
        // to assuming 64 bit.
        final String? abilist = await _getProperty('ro.product.cpu.abilist');
        if (abilist == null || abilist.contains('arm64-v8a')) {
          return CpuArch.arm64;
        } else {
          return CpuArch.armv7;
        }
      case 'armeabi-v7a':
        return CpuArch.armv7;
      case 'x86_64':
        return CpuArch.x64;
      default:
        return CpuArch.unknown;
    }
  }();

  @override
  Future<bool> supportsRuntimeMode(BuildMode buildMode) async {
    if ((await targetPlatform).os != .android) {
      throw UnsupportedError('Invalid target platform for Android');
    }
    return buildMode != BuildMode.jitRelease;
  }

  @override
  Future<String> get sdkNameAndVersion async =>
      'Android ${await _sdkVersion} (API ${await apiVersion})';

  Future<String?> get _sdkVersion => _getProperty('ro.build.version.release');

  @visibleForTesting
  Future<String?> get apiVersion => _getProperty('ro.build.version.sdk');

  AdbLogReader? _logReader;
  AdbLogReader? _pastLogReader;

  List<String> adbCommandForDevice(List<String> args) {
    return <String>[_androidSdk.adbPath!, '-s', id, ...args];
  }

  Future<RunResult> runAdbCheckedAsync(
    List<String> params, {
    String? workingDirectory,
    bool allowReentrantFlutter = false,
  }) async {
    return _processUtils.run(
      adbCommandForDevice(params),
      throwOnError: true,
      workingDirectory: workingDirectory,
      allowReentrantFlutter: allowReentrantFlutter,
      allowedFailures: (int value) => _allowHeapCorruptionOnWindows(value, _platform),
    );
  }

  bool _isValidAdbVersion(String adbVersion) {
    // Sample output: 'Android Debug Bridge version 1.0.31'
    final Match? versionFields = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion);
    if (versionFields != null) {
      final int majorVersion = int.parse(versionFields[1]!);
      final int minorVersion = int.parse(versionFields[2]!);
      final int patchVersion = int.parse(versionFields[3]!);
      if (majorVersion > 1) {
        return true;
      }
      if (majorVersion == 1 && minorVersion > 0) {
        return true;
      }
      if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 39) {
        return true;
      }
      return false;
    }
    _logger.printError('Unrecognized adb version string $adbVersion. Skipping version check.');
    return true;
  }

  Future<bool> _checkForSupportedAdbVersion() async {
    final String? adbPath = _androidSdk.adbPath;
    if (adbPath == null) {
      return false;
    }

    try {
      final RunResult adbVersion = await _processUtils.run(<String>[
        adbPath,
        'version',
      ], throwOnError: true);
      if (_isValidAdbVersion(adbVersion.stdout)) {
        return true;
      }
      _logger.printError(
        'The ADB at "$adbPath" is too old; please install version 1.0.39 or later.',
      );
    } on Exception catch (error, trace) {
      _logger.printError('Error running ADB: $error', stackTrace: trace);
    }

    return false;
  }

  Future<bool> _checkForSupportedAndroidVersion() async {
    final String? adbPath = _androidSdk.adbPath;
    if (adbPath == null) {
      return false;
    }
    try {
      // If the server is automatically restarted, then we get irrelevant
      // output lines like this, which we want to ignore:
      //   adb server is out of date. killing..
      //   * daemon started successfully *
      await _processUtils.run(<String>[adbPath, 'start-server'], throwOnError: true);

      // This has been reported to return null on some devices. In this case,
      // assume the lowest supported API to still allow Flutter to run.
      // Sample output: '24'
      final String sdkVersion =
          await _getProperty('ro.build.version.sdk') ?? gradle_utils.minSdkVersion;

      final int? sdkVersionParsed = int.tryParse(sdkVersion);
      if (sdkVersionParsed == null) {
        _logger.printError('Unexpected response from getprop: "$sdkVersion"');
        return false;
      }

      if (sdkVersionParsed < gradle_utils.minSdkVersionInt) {
        _logger.printError(
          'The Android version ($sdkVersion) on the target device is too old. Please '
          'use a API ${gradle_utils.minSdkVersion} device or later.',
        );
        return false;
      }

      return true;
    } on Exception catch (e, stacktrace) {
      _logger.printError('Unexpected failure from adb: $e');
      _logger.printError('Stacktrace: $stacktrace');
      return false;
    }
  }

  String _getDeviceSha1Path(AndroidApk apk) {
    return '/data/local/tmp/sky.${apk.id}.sha1';
  }

  Future<String> _getDeviceApkSha1(AndroidApk apk) async {
    final RunResult result = await _processUtils.run(
      adbCommandForDevice(<String>['shell', 'cat', _getDeviceSha1Path(apk)]),
    );
    return result.stdout;
  }

  String _getSourceSha1(AndroidApk apk) {
    final File shaFile = _fileSystem.file('${apk.applicationPackage.path}.sha1');
    return shaFile.existsSync() ? shaFile.readAsStringSync() : '';
  }

  @override
  String get name => modelID;

  @override
  bool get supportsFlavors => true;

  @override
  Future<bool> isAppInstalled(ApplicationPackage app, {String? userIdentifier}) async {
    // This call takes 400ms - 600ms.
    try {
      final RunResult listOut = await runAdbCheckedAsync(<String>[
        'shell',
        'pm',
        'list',
        'packages',
        if (userIdentifier != null) ...<String>['--user', userIdentifier],
        app.id,
      ]);
      return LineSplitter.split(listOut.stdout).contains('package:${app.id}');
    } on Exception catch (error) {
      _logger.printTrace('$error');
      return false;
    }
  }

  @override
  Future<bool> isLatestBuildInstalled(covariant AndroidApk app) async {
    final String installedSha1 = await _getDeviceApkSha1(app);
    return installedSha1.isNotEmpty && installedSha1 == _getSourceSha1(app);
  }

  @override
  Future<bool> installApp(covariant AndroidApk app, {String? userIdentifier}) async {
    if (!await _adbIsValid) {
      return false;
    }
    _logger.printTrace('Installing APK.');
    if (await _installApp(app, userIdentifier: userIdentifier)) {
      return true;
    }
    _logger.printTrace('Warning: Failed to install APK.');
    if (!await isAppInstalled(app, userIdentifier: userIdentifier)) {
      return false;
    }
    _logger.printStatus('Uninstalling old version...');
    if (!await uninstallApp(app, userIdentifier: userIdentifier)) {
      _logger.printError('Error: Uninstalling old version failed.');
      return false;
    }
    if (!await _installApp(app, userIdentifier: userIdentifier)) {
      _logger.printError('Error: Failed to install APK again.');
      return false;
    }
    return true;
  }

  Future<bool> _installApp(AndroidApk app, {String? userIdentifier}) async {
    if (!app.applicationPackage.existsSync()) {
      _logger.printError(
        '"${_fileSystem.path.relative(app.applicationPackage.path)}" does not exist.',
      );
      return false;
    }

    final Status status = _logger.startProgress(
      'Installing ${_fileSystem.path.relative(app.applicationPackage.path)}...',
    );
    final RunResult installResult = await _processUtils.run(
      adbCommandForDevice(<String>[
        'install',
        '-t',
        '-r',
        if (userIdentifier != null) ...<String>['--user', userIdentifier],
        app.applicationPackage.path,
      ]),
    );
    status.stop();
    // Some versions of adb exit with exit code 0 even on failure :(
    // Parsing the output to check for failures.
    final failureExp = RegExp(r'^Failure.*$', multiLine: true);
    final String? failure = failureExp.stringMatch(installResult.stdout);
    if (failure != null) {
      _logger.printError('Package install error: $failure');
      return false;
    }
    if (installResult.exitCode != 0) {
      if (installResult.stderr.contains('Bad user number')) {
        _logger.printError(
          'Error: User "$userIdentifier" not found. Run "adb shell pm list users" to see list of available identifiers.',
        );
      } else {
        _logger.printError('Error: ADB exited with exit code ${installResult.exitCode}');
        _logger.printError('$installResult');
      }
      return false;
    }
    try {
      await runAdbCheckedAsync(<String>[
        'shell',
        'echo',
        '-n',
        _getSourceSha1(app),
        '>',
        _getDeviceSha1Path(app),
      ]);
    } on ProcessException catch (error) {
      _logger.printError('adb shell failed to write the SHA hash: $error.');
      return false;
    }
    return true;
  }

  @override
  Future<bool> uninstallApp(ApplicationPackage app, {String? userIdentifier}) async {
    if (!await _adbIsValid) {
      return false;
    }

    String uninstallOut;
    try {
      final RunResult uninstallResult = await _processUtils.run(
        adbCommandForDevice(<String>[
          'uninstall',
          if (userIdentifier != null) ...<String>['--user', userIdentifier],
          app.id,
        ]),
        throwOnError: true,
        timeout: const Duration(seconds: 30),
      );
      uninstallOut = uninstallResult.stdout;
    } on Exception catch (error) {
      _logger.printError('adb uninstall failed: $error');
      return false;
    }
    final failureExp = RegExp(r'^Failure.*$', multiLine: true);
    final String? failure = failureExp.stringMatch(uninstallOut);
    if (failure != null) {
      _logger.printError('Package uninstall error: $failure');
      return false;
    }
    return true;
  }

  // Whether the adb and Android versions are aligned.
  late final Future<bool> _adbIsValid = () async {
    return await _checkForSupportedAdbVersion() && await _checkForSupportedAndroidVersion();
  }();

  AndroidApk? _package;

  @override
  Future<LaunchResult> startApp(
    AndroidApk? package, {
    String? mainPath,
    String? route,
    required DebuggingOptions debuggingOptions,
    Map<String, Object?> platformArgs = const <String, Object>{},
    bool prebuiltApplication = false,
    bool ipv6 = false,
    String? userIdentifier,
  }) async {
    if (!await _adbIsValid) {
      return LaunchResult.failed();
    }

    final TargetPlatform devicePlatform = await targetPlatform;

    var builtPackage = package;
    final CpuArch cpuArch = await this.cpuArch;
    switch (cpuArch) {
      case CpuArch.armv7:
      case CpuArch.arm64:
      case CpuArch.x64:
        break;
      case CpuArch.x86:
      case CpuArch.riscv64:
      case CpuArch.unknown:
        _logger.printError('Android platforms are only supported.');
        return LaunchResult.failed();
    }

    if (!prebuiltApplication ||
        _androidSdk.licensesAvailable && _androidSdk.latestVersion == null) {
      _logger.printTrace('Building APK');
      final FlutterProject project = FlutterProject.current();

      final releaseManifestEngineShellArgs = <String>[
        if (debuggingOptions.buildInfo.mode == BuildMode.release) ...<String>[
          ...debuggingOptions.getAndroidLaunchArguments(),
          if (platformArgs[AndroidEngineCliFlags.traceStartup] as bool? ?? false)
            '--${AndroidEngineCliFlags.traceStartup}',
          if (route != null) '--${AndroidEngineCliFlags.route}=$route',
        ],
      ];

      await androidBuilder!.buildApk(
        project: project,
        target: mainPath ?? 'lib/main.dart',
        androidBuildInfo: AndroidBuildInfo(
          debuggingOptions.buildInfo,
          targetArchs: <CpuArch>[cpuArch],
          releaseManifestEngineShellArgs: releaseManifestEngineShellArgs.isEmpty
              ? null
              : releaseManifestEngineShellArgs,
        ),
      );
      // Package has been built, so we can get the updated application ID and
      // activity name from the .apk.
      builtPackage = await ApplicationPackageFactory.instance!.getPackageForPlatform(
        devicePlatform,
        buildInfo: debuggingOptions.buildInfo,
      ) as AndroidApk?;
    }
    // There was a failure parsing the android project information.
    if (builtPackage == null) {
      throwToolExit('Problem building Android application: see above error(s).');
    }

    _logger.printTrace("Stopping app '${builtPackage.name}' on $name.");
    await stopApp(builtPackage, userIdentifier: userIdentifier);

    if (!await installApp(builtPackage, userIdentifier: userIdentifier)) {
      return LaunchResult.failed();
    }

    final bool traceStartup = platformArgs[AndroidEngineCliFlags.traceStartup] as bool? ?? false;
    ProtocolDiscovery? vmServiceDiscovery;

    if (debuggingOptions.debuggingEnabled) {
      vmServiceDiscovery = ProtocolDiscovery.vmService(
        // Avoid using getLogReader, which returns a singleton instance, because the
        // VM Service discovery will dispose at the end. creating a new logger here allows
        // logs to be surfaced normally during `flutter drive`.
        await AdbLogReader.createLogReader(
          this,
          _processManager,
          _logger,
          adbLogFiltering: debuggingOptions.adbLogFiltering,
        ),
        portForwarder: portForwarder,
        hostPort: debuggingOptions.hostVmServicePort,
        devicePort: debuggingOptions.deviceVmServicePort,
        ipv6: ipv6,
        logger: _logger,
      );
    }

    final cmd = <String>[
      'shell', 'am', 'start',
      '-a', 'android.intent.action.MAIN',
      '-c', 'android.intent.category.LAUNCHER',
      '-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP
      if (debuggingOptions.buildInfo.mode != BuildMode.release) ...<String>[
        ...debuggingOptions.getAndroidLaunchArgumentsAsIntentExtras(),
        if (traceStartup) ...<String>['--ez', AndroidEngineCliFlags.traceStartup, 'true'],
        if (route != null) ...<String>['--es', AndroidEngineCliFlags.route, route],
      ],
      if (debuggingOptions.debuggingEnabled && userIdentifier != null) ...<String>[
        '--user',
        userIdentifier,
      ],
      builtPackage.launchActivity,
    ];
    final String result = (await runAdbCheckedAsync(cmd)).stdout;
    // This invocation returns 0 even when it fails.
    if (result.contains(
      RegExp(r'(Error:|Error type|Security\s?exception)', caseSensitive: false),
    )) {
      _logger.printError(result.trim(), wrap: false);
      return LaunchResult.failed();
    }

    _package = builtPackage;
    if (!debuggingOptions.debuggingEnabled) {
      return LaunchResult.succeeded();
    }

    // Wait for the service protocol port here. This will complete once the
    // device has printed "VM Service is listening on...".
    _logger.printTrace('Waiting for VM Service port to be available...');
    try {
      Uri? vmServiceUri;
      if (debuggingOptions.buildInfo.isDebug || debuggingOptions.buildInfo.isProfile) {
        vmServiceUri = await vmServiceDiscovery?.uri;
        if (vmServiceUri == null) {
          _logger.printError(
            'Error waiting for a debug connection: '
            'The log reader stopped unexpectedly',
          );
          return LaunchResult.failed();
        }
      }
      return LaunchResult.succeeded(vmServiceUri: vmServiceUri);
    } on Exception catch (error) {
      _logger.printError('Error waiting for a debug connection: $error');
      return LaunchResult.failed();
    } finally {
      await vmServiceDiscovery?.cancel();
    }
  }

  @override
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;

  @override
  Future<bool> stopApp(ApplicationPackage? app, {String? userIdentifier}) async {
    if (app == null) {
      return false;
    }
    final List<String> command = adbCommandForDevice(<String>[
      'shell',
      'am',
      'force-stop',
      if (userIdentifier != null) ...<String>['--user', userIdentifier],
      app.id,
    ]);
    try {
      final RunResult result = await _processUtils.run(
        command,
        timeout: const Duration(seconds: 30),
      );
      final int exitCode = result.exitCode;
      return exitCode == 0 || _allowHeapCorruptionOnWindows(exitCode, _platform);
    } on Exception catch (error) {
      _logger.printError('adb shell am force-stop failed: $error');
      return false;
    }
  }

  @override
  Future<MemoryInfo> queryMemoryInfo() async {
    final AndroidApk? package = _package;
    if (package == null) {
      _logger.printError('Android package unknown, skipping dumpsys meminfo.');
      return const MemoryInfo.empty();
    }
    final RunResult runResult = await _processUtils.run(
      adbCommandForDevice(<String>['shell', 'dumpsys', 'meminfo', package.id, '-d']),
    );

    if (runResult.exitCode != 0) {
      return const MemoryInfo.empty();
    }
    return parseMeminfoDump(runResult.stdout);
  }

  @override
  void clearLogs() {
    final RunResult result = _processUtils.runSync(adbCommandForDevice(<String>['logcat', '-c']));
    // Do not log to standard error because that causes test to fail.
    if (result.exitCode != 0) {
      _logger.printTrace(
        '"adb logcat -c" failed: exitCode: ${result.exitCode}'
        ' stdout: ${result.stdout} stderr: ${result.stderr}',
      );
    }
  }

  @override
  FutureOr<DeviceLogReader> getLogReader({
    ApplicationPackage? app,
    bool includePastLogs = false,
    bool adbLogFiltering = true,
  }) async {
    // The Android log reader isn't app-specific. The `app` parameter isn't used.
    if (includePastLogs) {
      return _pastLogReader ??= await AdbLogReader.createLogReader(
        this,
        _processManager,
        _logger,
        includePastLogs: true,
        adbLogFiltering: adbLogFiltering,
      );
    } else {
      return _logReader ??= await AdbLogReader.createLogReader(
        this,
        _processManager,
        _logger,
        adbLogFiltering: adbLogFiltering,
      );
    }
  }

  @override
  VMServiceDiscoveryForAttach getVMServiceDiscoveryForAttach({
    String? appId,
    String? fuchsiaModule,
    int? filterDevicePort,
    int? expectedHostPort,
    required bool ipv6,
    required Logger logger,
  }) => LogScanningVMServiceDiscoveryForAttach(
    // If it's an Android device, attaching relies on past log searching
    // to find the service protocol.
    Future<DeviceLogReader>.value(getLogReader(includePastLogs: true)),
    portForwarder: portForwarder,
    ipv6: ipv6,
    devicePort: filterDevicePort,
    hostPort: expectedHostPort,
    logger: logger,
  );

  @override
  late final DevicePortForwarder? portForwarder = () {
    final String? adbPath = _androidSdk.adbPath;
    if (adbPath == null) {
      return null;
    }
    return AndroidDevicePortForwarder(
      processManager: _processManager,
      logger: _logger,
      deviceId: id,
      adbPath: adbPath,
    );
  }();

  static final _timeRegExp = RegExp(r'^\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}', multiLine: true);

  /// Return the most recent timestamp in the Android log or `null` if there is
  /// no available timestamp. The format can be passed to logcat's -T option.
  @visibleForTesting
  Future<String?> lastLogcatTimestamp() async {
    RunResult output;
    try {
      output = await runAdbCheckedAsync(<String>['shell', '-x', 'logcat', '-v', 'time', '-t', '1']);
    } on Exception catch (error) {
      _logger.printError(
        'Failed to extract the most recent timestamp from the Android log: $error.',
      );
      return null;
    }
    final Match? timeMatch = _timeRegExp.firstMatch(output.stdout);
    return timeMatch?.group(0);
  }

  @override
  Future<bool> isSupported() async {
    return (await targetPlatform).os == .android;
  }

  @override
  bool get supportsScreenshot => true;

  @override
  Future<void> takeScreenshot(File outputFile) async {
    const remotePath = '/data/local/tmp/flutter_screenshot.png';
    await runAdbCheckedAsync(<String>['shell', 'screencap', '-p', remotePath]);
    await _processUtils.run(
      adbCommandForDevice(<String>['pull', remotePath, outputFile.path]),
      throwOnError: true,
    );
    await runAdbCheckedAsync(<String>['shell', 'rm', remotePath]);
  }

  @override
  bool isSupportedForProject(FlutterProject flutterProject) {
    return flutterProject.android.existsSync();
  }

  @override
  Future<void> dispose() async {
    _logReader?._stop();
    _pastLogReader?._stop();
  }
}

Map<String, String> parseAdbDeviceProperties(String str) {
  final properties = <String, String>{};
  final propertyExp = RegExp(r'\[(.*?)\]: \[(.*?)\]');
  for (final Match match in propertyExp.allMatches(str)) {
    properties[match.group(1)!] = match.group(2)!;
  }
  return properties;
}

/// Process the dumpsys info formatted in a table-like structure.
///
/// Currently this only pulls information from the  "App Summary" subsection.
///
/// Example output:
///
/// ```none
/// Applications Memory Usage (in Kilobytes):
/// Uptime: 441088659 Realtime: 521464097
///
/// ** MEMINFO in pid 16141 [io.flutter.demo.gallery] **
///                    Pss  Private  Private  SwapPss     Heap     Heap     Heap
///                  Total    Dirty    Clean    Dirty     Size    Alloc     Free
///                 ------   ------   ------   ------   ------   ------   ------
///   Native Heap     8648     8620        0       16    20480    12403     8076
///   Dalvik Heap      547      424       40       18     2628     1092     1536
///  Dalvik Other      464      464        0        0
///         Stack      496      496        0        0
///        Ashmem        2        0        0        0
///       Gfx dev      212      204        0        0
///     Other dev       48        0       48        0
///      .so mmap    10770      708     9372       25
///     .apk mmap      240        0        0        0
///     .ttf mmap       35        0       32        0
///     .dex mmap     2205        4     1172        0
///     .oat mmap       64        0        0        0
///     .art mmap     4228     3848       24        2
///    Other mmap    20713        4    20704        0
///     GL mtrack     2380     2380        0        0
///       Unknown    43971    43968        0        1
///         TOTAL    95085    61120    31392       62    23108    13495     9612
///
///  App Summary
///                        Pss(KB)
///                         ------
///            Java Heap:     4296
///          Native Heap:     8620
///                 Code:    11288
///                Stack:      496
///             Graphics:     2584
///        Private Other:    65228
///               System:     2573
///
///                TOTAL:    95085       TOTAL SWAP PSS:       62
///
///  Objects
///                Views:        9         ViewRootImpl:        1
///          AppContexts:        3           Activities:        1
///              Assets:        4        AssetManagers:        3
///        Local Binders:       10        Proxy Binders:       18
///        Parcel memory:        6         Parcel count:       24
///     Death Recipients:        0      OpenSSL Sockets:        0
///             WebViews:        0
///
///  SQL
///          MEMORY_USED:        0
///   PAGECACHE_OVERFLOW:        0          MALLOC_SIZE:        0
/// ...
/// ```
///
/// For more information, see https://developer.android.com/studio/command-line/dumpsys.
@visibleForTesting
AndroidMemoryInfo parseMeminfoDump(String input) {
  final androidMemoryInfo = AndroidMemoryInfo();

  final List<String> lines = input.split('\n');

  final String timelineData = lines.firstWhere(
    (String line) => line.startsWith('${AndroidMemoryInfo._kUpTimeKey}: '),
  );
  final List<String> times = timelineData.trim().split('${AndroidMemoryInfo._kRealTimeKey}:');
  androidMemoryInfo.realTime = int.tryParse(times.last.trim()) ?? 0;

  lines
      .skipWhile((String line) => !line.contains('App Summary'))
      .takeWhile((String line) => !line.contains('TOTAL'))
      .where((String line) => line.contains(':'))
      .forEach((String line) {
        final List<String> sections = line.trim().split(':');
        final String key = sections.first.trim();
        final int value = int.tryParse(sections.last.trim()) ?? 0;
        switch (key) {
          case AndroidMemoryInfo._kJavaHeapKey:
            androidMemoryInfo.javaHeap = value;
          case AndroidMemoryInfo._kNativeHeapKey:
            androidMemoryInfo.nativeHeap = value;
          case AndroidMemoryInfo._kCodeKey:
            androidMemoryInfo.code = value;
          case AndroidMemoryInfo._kStackKey:
            androidMemoryInfo.stack = value;
          case AndroidMemoryInfo._kGraphicsKey:
            androidMemoryInfo.graphics = value;
          case AndroidMemoryInfo._kPrivateOtherKey:
            androidMemoryInfo.privateOther = value;
          case AndroidMemoryInfo._kSystemKey:
            androidMemoryInfo.system = value;
        }
      });
  return androidMemoryInfo;
}

/// Android specific implementation of memory info.
class AndroidMemoryInfo extends MemoryInfo {
  static const _kUpTimeKey = 'Uptime';
  static const _kRealTimeKey = 'Realtime';
  static const _kJavaHeapKey = 'Java Heap';
  static const _kNativeHeapKey = 'Native Heap';
  static const _kCodeKey = 'Code';
  static const _kStackKey = 'Stack';
  static const _kGraphicsKey = 'Graphics';
  static const _kPrivateOtherKey = 'Private Other';
  static const _kSystemKey = 'System';
  static const _kTotalKey = 'Total';

  // Realtime is time since the system was booted includes deep sleep. Clock
  // is monotonic, and ticks even when the CPU is in power saving modes.
  int realTime = 0;

  // Each measurement has KB as a unit.
  int javaHeap = 0;
  int nativeHeap = 0;
  int code = 0;
  int stack = 0;
  int graphics = 0;
  int privateOther = 0;
  int system = 0;

  @override
  Map<String, Object> toJson() {
    return <String, Object>{
      'platform': 'Android',
      _kRealTimeKey: realTime,
      _kJavaHeapKey: javaHeap,
      _kNativeHeapKey: nativeHeap,
      _kCodeKey: code,
      _kStackKey: stack,
      _kGraphicsKey: graphics,
      _kPrivateOtherKey: privateOther,
      _kSystemKey: system,
      _kTotalKey: javaHeap + nativeHeap + code + stack + graphics + privateOther + system,
    };
  }
}

/// A log reader that logs from `adb logcat`.
class AdbLogReader extends DeviceLogReader {
  AdbLogReader._(this._adbProcess, this.name, this._logger, {this.adbLogFiltering = true});

  @visibleForTesting
  factory AdbLogReader.test(
    Process adbProcess,
    String name,
    Logger logger, {
    bool adbLogFiltering = true,
  }) {
    return AdbLogReader._(adbProcess, name, logger, adbLogFiltering: adbLogFiltering);
  }

  /// Create a new [AdbLogReader] from an [AndroidDevice] instance.
  static Future<AdbLogReader> createLogReader(
    AndroidDevice device,
    ProcessManager processManager,
    Logger logger, {
    bool includePastLogs = false,
    bool adbLogFiltering = true,
  }) async {
    // logcat -T is not supported on Android releases before Lollipop.
    const kLollipopVersionCode = 21;
    final int? apiVersion = (String? v) {
      // If the API version string isn't found, conservatively assume that the
      // version is less recent than the one we're looking for.
      return v == null ? kLollipopVersionCode - 1 : int.tryParse(v);
    }(await device.apiVersion);

    // Start the adb logcat process and filter the most recent logs since `lastTimestamp`.
    // Some devices (notably LG) will only output logcat via shell
    // https://github.com/flutter/flutter/issues/51853
    final args = <String>['shell', '-x', 'logcat', '-v', 'time'];

    // If past logs are included then filter for 'flutter' logs only.
    if (includePastLogs) {
      args.addAll(<String>['-s', 'flutter']);
    } else if (apiVersion != null && apiVersion >= kLollipopVersionCode) {
      // Otherwise, filter for logs appearing past the present.
      // '-T 0` means the timestamp of the logcat command invocation.
      final String? lastLogcatTimestamp = await device.lastLogcatTimestamp();
      args.addAll(<String>[
        '-T',
        if (lastLogcatTimestamp != null) "'$lastLogcatTimestamp'" else '0',
      ]);
    }
    final Process process = await processManager.start(device.adbCommandForDevice(args));
    return AdbLogReader._(process, device.displayName, logger, adbLogFiltering: adbLogFiltering);
  }

  int? _appPid;

  final Process _adbProcess;

  final Logger _logger;

  final bool adbLogFiltering;

  @override
  final String name;

  late final _linesController = StreamController<String>.broadcast(
    onListen: _start,
    onCancel: _stop,
  );

  @override
  Stream<String> get logLines => _linesController.stream;

  @override
  Future<void> provideVmService(FlutterVmService connectedVmService) async {
    final VM? vm = await connectedVmService.getVmGuarded();
    if (vm == null) {
      _logger.printError(
        'An error occurred when setting up filtering for adb logs. '
        'Unable to communicate with the VM service.',
      );
    } else {
      _appPid = vm.pid;
    }
  }

  void _start() {
    // We expect logcat streams to occasionally contain invalid utf-8,
    // see: https://github.com/flutter/flutter/pull/8864.
    const decoder = Utf8Decoder(reportErrors: false);
    _adbProcess.stdout
        .transformWithCallSite(decoder)
        .transform(const LineSplitter())
        .listen(_onLine);
    _adbProcess.stderr
        .transformWithCallSite(decoder)
        .transform(const LineSplitter())
        .listen(_onLine);
    unawaited(
      _adbProcess.exitCode.whenComplete(() {
        if (_linesController.hasListener) {
          _linesController.close();
        }
      }),
    );
  }

  // 'W/ActivityManager(pid): '
  static final _logFormat = RegExp(r'^[VDIWEF]\/.*?\(\s*(\d+)\):\s');

  static final _allowedTags = <RegExp>[
    RegExp(r'^[VDIWEF]\/flutter[^:]*:\s+', caseSensitive: false),
    RegExp(r'^[IE]\/DartVM[^:]*:\s+'),
    RegExp(r'^[WEF]\/AndroidRuntime:\s+'),
    RegExp(r'^[WEF]\/AndroidRuntime\([0-9]+\):\s+'),
    RegExp(r'^[WEF]\/ActivityManager:\s+.*(\bflutter\b|\bdomokit\b|\bsky\b)'),
    RegExp(r'^[WEF]\/System\.err:\s+'),
    RegExp(r'^[F]\/[\S^:]+:\s+'),
  ];

  static final _filteredMessagees = <RegExp>[
    // E/SurfaceSyncer(22636): Failed to find sync for id=9
    // Some versions of Android spew this out. It is inactionable to the end user
    // and causes no problems for the application.
    RegExp(r'^E/SurfaceSyncer\(\s*\d+\): Failed to find sync for id=\d+'),
    // E/FrameEvents(26685): updateAcquireFence: Did not find frame.
    // This is a HWUI bug that spams the console when using platform views like Google Maps.
    // It is not an actual error and causes no problems for the application.
    // See https://github.com/flutter/flutter/issues/104268
    RegExp(r'^E/FrameEvents\(\s*\d+\): updateAcquireFence: Did not find frame\.$'),
    // This warning is spammy on some devices and does not affect functionality.
    // See https://github.com/flutter/flutter/issues/174783
    RegExp(r'^W/MotionEvent-JNI\(\s*\d+\): android_view_MotionEvent_nativeGetPointerCount: -1$'),
    // See https://github.com/flutter/flutter/issues/160598
    RegExp(r'ViewPostIme pointer'),
    RegExp(r'mali.instrumentation.graph.work'),
  ];

  // 'F/libc(pid): Fatal signal 11'
  static final _fatalLog = RegExp(r'^F\/libc\s*\(\s*\d+\):\sFatal signal (\d+)');

  // 'I/DEBUG(pid): ...'
  static final _tombstoneLine = RegExp(r'^[IF]\/DEBUG\s*\(\s*\d+\):\s(.+)$');

  // 'I/DEBUG(pid): Tombstone written to: '
  static final _tombstoneTerminator = RegExp(r'^Tombstone written to:\s');

  // we default to true in case none of the log lines match
  var _acceptedLastLine = true;

  // Whether a fatal crash is happening or not.
  // During a fatal crash only lines from the crash are accepted, the rest are
  // dropped.
  var _fatalCrash = false;

  // The format of the line is controlled by the '-v' parameter passed to
  // adb logcat. We are currently passing 'time', which has the format:
  // mm-dd hh:mm:ss.milliseconds Priority/Tag( PID): ....
  void _onLine(String line) {
    // This line might be processed after the subscription is closed but before
    // adb stops streaming logs.
    if (_linesController.isClosed) {
      return;
    }
    final Match? timeMatch = AndroidDevice._timeRegExp.firstMatch(line);
    if (timeMatch == null || line.length == timeMatch.end) {
      _acceptedLastLine = false;
      return;
    }
    // Chop off the time.
    line = line.substring(timeMatch.end + 1);
    final Match? logMatch = _logFormat.firstMatch(line);
    if (logMatch != null) {
      var acceptLine = false;

      if (!adbLogFiltering) {
        acceptLine = true;
      } else if (_fatalCrash) {
        // While a fatal crash is going on, only accept lines from the crash
        // Otherwise the crash log in the console may get interrupted

        final Match? fatalMatch = _tombstoneLine.firstMatch(line);

        if (fatalMatch != null) {
          acceptLine = true;

          line = fatalMatch[1]!;

          if (_tombstoneTerminator.hasMatch(line)) {
            // Hit crash terminator, stop logging the crash info
            _fatalCrash = false;
          }
        }
      } else if (_appPid != null && int.parse(logMatch.group(1)!) == _appPid) {
        acceptLine = !_filteredMessagees.any((RegExp e) => e.hasMatch(line));

        if (_fatalLog.hasMatch(line)) {
          // Hit fatal signal, app is now crashing
          _fatalCrash = true;
        }
      } else {
        // Filter on approved names and levels.
        acceptLine = _allowedTags.any((RegExp re) => re.hasMatch(line));
      }

      if (acceptLine) {
        _acceptedLastLine = true;
        _linesController.add(line);
        return;
      }
      _acceptedLastLine = false;
    } else if (line == '--------- beginning of system' || line == '--------- beginning of main') {
      // hide the ugly adb logcat log boundaries at the start
      _acceptedLastLine = false;
    } else {
      // If it doesn't match the log pattern at all, then pass it through if we
      // passed the last matching line through. It might be a multiline message.
      if (_acceptedLastLine) {
        _linesController.add(line);
        return;
      }
    }
  }

  void _stop() {
    _linesController.close();
    _adbProcess.kill();
  }

  @override
  void dispose() {
    _stop();
  }
}

/// A [DevicePortForwarder] implemented for Android devices that uses adb.
class AndroidDevicePortForwarder extends DevicePortForwarder {
  AndroidDevicePortForwarder({
    required ProcessManager processManager,
    required Logger logger,
    required this._deviceId,
    required this._adbPath,
  }) : _logger = logger,
       _processUtils = ProcessUtils(logger: logger, processManager: processManager);

  final String _deviceId;
  final String _adbPath;
  final Logger _logger;
  final ProcessUtils _processUtils;

  static int? _extractPort(String portString) {
    return int.tryParse(portString.trim());
  }

  @override
  List<ForwardedPort> get forwardedPorts {
    final ports = <ForwardedPort>[];

    String stdout;
    try {
      stdout = _processUtils
          .runSync(<String>[_adbPath, '-s', _deviceId, 'forward', '--list'], throwOnError: true)
          .stdout
          .trim();
    } on ProcessException catch (error) {
      _logger.printError('Failed to list forwarded ports: $error.');
      return ports;
    }

    final List<String> lines = LineSplitter.split(stdout).toList();
    for (final line in lines) {
      if (!line.startsWith(_deviceId)) {
        continue;
      }
      final List<String> splitLine = line.split('tcp:');

      // Sanity check splitLine.
      if (splitLine.length != 3) {
        continue;
      }

      // Attempt to extract ports.
      final int? hostPort = _extractPort(splitLine[1]);
      final int? devicePort = _extractPort(splitLine[2]);

      // Failed, skip.
      if (hostPort == null || devicePort == null) {
        continue;
      }

      ports.add(ForwardedPort(hostPort, devicePort));
    }

    return ports;
  }

  @override
  Future<int> forward(int devicePort, {int? hostPort}) async {
    hostPort ??= 0;
    final RunResult process = await _processUtils.run(<String>[
      _adbPath,
      '-s',
      _deviceId,
      'forward',
      'tcp:$hostPort',
      'tcp:$devicePort',
    ], throwOnError: true);

    if (process.stderr.isNotEmpty) {
      process.throwException('adb returned error:\n${process.stderr}');
    }

    if (process.exitCode != 0) {
      if (process.stdout.isNotEmpty) {
        process.throwException('adb returned error:\n${process.stdout}');
      }
      process.throwException('adb failed without a message');
    }

    if (hostPort == 0) {
      if (process.stdout.isEmpty) {
        process.throwException('adb did not report forwarded port');
      }
      hostPort = int.tryParse(process.stdout);
      if (hostPort == null) {
        process.throwException('adb returned invalid port number:\n${process.stdout}');
      }
    } else {
      // stdout may be empty or the port we asked it to forward, though it's
      // not documented (or obvious) what triggers each case.
      //
      // Observations are:
      //   - On MacOS it's always empty when Flutter spawns the process, but
      //   - On MacOS it prints the port number when run from the terminal, unless
      //     the port is already forwarded, when it also prints nothing.
      //   - On ChromeOS, the port appears to be printed even when Flutter spawns
      //     the process
      //
      // To cover all cases, we accept the output being either empty or exactly
      // the port number, but treat any other output as probably being an error
      // message.
      if (process.stdout.isNotEmpty && process.stdout.trim() != '$hostPort') {
        process.throwException('adb returned error:\n${process.stdout}');
      }
    }

    return hostPort!;
  }

  @override
  Future<void> unforward(ForwardedPort forwardedPort) async {
    final tcpLine = 'tcp:${forwardedPort.hostPort}';
    final RunResult runResult = await _processUtils.run(<String>[
      _adbPath,
      '-s',
      _deviceId,
      'forward',
      '--remove',
      tcpLine,
    ]);
    if (runResult.exitCode == 0) {
      return;
    }
    _logger.printError('Failed to unforward port: $runResult');
  }

  @override
  Future<void> dispose() async {
    for (final ForwardedPort port in forwardedPorts) {
      await unforward(port);
    }
  }
}

// In platform tools 29.0.0 adb.exe seems to be ending with this heap
// corruption error code on seemingly successful termination. Ignore
// this error on windows.
bool _allowHeapCorruptionOnWindows(int exitCode, Platform platform) {
  return exitCode == -1073740940 && platform.isWindows;
}