// 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 'dart:math' as math;

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

import '../application_package.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/process.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../convert.dart';
import '../devfs.dart';
import '../device.dart';
import '../device_port_forwarder.dart';
import '../globals.dart' as globals;
import '../macos/xcode.dart';
import '../project.dart';
import '../protocol_discovery.dart';
import 'application_package.dart';
import 'mac.dart';
import 'plist_parser.dart';

const String iosSimulatorId = 'apple_ios_simulator';

class IOSSimulators extends PollingDeviceDiscovery {
  IOSSimulators({
    required IOSSimulatorUtils iosSimulatorUtils,
  }) : _iosSimulatorUtils = iosSimulatorUtils,
       super('iOS simulators');

  final IOSSimulatorUtils _iosSimulatorUtils;

  @override
  bool get supportsPlatform => globals.platform.isMacOS;

  @override
  bool get canListAnything => globals.iosWorkflow?.canListDevices ?? false;

  @override
  Future<List<Device>> pollingGetDevices({ Duration? timeout }) async => _iosSimulatorUtils.getAttachedDevices();

  @override
  List<String> get wellKnownIds => const <String>[];
}

class IOSSimulatorUtils {
  IOSSimulatorUtils({
    required Xcode xcode,
    required Logger logger,
    required ProcessManager processManager,
  })  : _simControl = SimControl(
          logger: logger,
          processManager: processManager,
          xcode: xcode,
        ),
        _xcode = xcode;

  final SimControl _simControl;
  final Xcode _xcode;

  Future<List<IOSSimulator>> getAttachedDevices() async {
    if (!_xcode.isInstalledAndMeetsVersionCheck) {
      return <IOSSimulator>[];
    }

    final List<SimDevice> connected = await _simControl.getConnectedDevices();
    return connected.map<IOSSimulator?>((SimDevice device) {
      final String? udid = device.udid;
      final String? name = device.name;
      if (udid == null) {
        globals.printTrace('Could not parse simulator udid');
        return null;
      }
      if (name == null) {
        globals.printTrace('Could not parse simulator name');
        return null;
      }
      return IOSSimulator(
        udid,
        name: name,
        simControl: _simControl,
        simulatorCategory: device.category,
      );
    }).whereType<IOSSimulator>().toList();
  }
}

/// A wrapper around the `simctl` command line tool.
class SimControl {
  SimControl({
    required Logger logger,
    required ProcessManager processManager,
    required Xcode xcode,
  })  : _logger = logger,
        _xcode = xcode,
        _processUtils = ProcessUtils(processManager: processManager, logger: logger);

  final Logger _logger;
  final ProcessUtils _processUtils;
  final Xcode _xcode;

  /// Runs `simctl list --json` and returns the JSON of the corresponding
  /// [section].
  Future<Map<String, Object?>> _list(SimControlListSection section) async {
    // Sample output from `simctl list --json`:
    //
    // {
    //   "devicetypes": { ... },
    //   "runtimes": { ... },
    //   "devices" : {
    //     "com.apple.CoreSimulator.SimRuntime.iOS-8-2" : [
    //       {
    //         "state" : "Shutdown",
    //         "availability" : " (unavailable, runtime profile not found)",
    //         "name" : "iPhone 4s",
    //         "udid" : "1913014C-6DCB-485D-AC6B-7CD76D322F5B"
    //       },
    //       ...
    //   },
    //   "pairs": { ... },

    final List<String> command = <String>[
      ..._xcode.xcrunCommand(),
      'simctl',
      'list',
      '--json',
      section.name,
    ];
    _logger.printTrace(command.join(' '));
    final RunResult results = await _processUtils.run(command);
    if (results.exitCode != 0) {
      _logger.printError('Error executing simctl: ${results.exitCode}\n${results.stderr}');
      return <String, Map<String, Object?>>{};
    }
    try {
      final Object? decodeResult = (json.decode(results.stdout) as Map<String, Object?>)[section.name];
      if (decodeResult is Map<String, Object?>) {
        return decodeResult;
      }
      _logger.printError('simctl returned unexpected JSON response: ${results.stdout}');
      return <String, Object>{};
    } on FormatException {
      // We failed to parse the simctl output, or it returned junk.
      // One known message is "Install Started" isn't valid JSON but is
      // returned sometimes.
      _logger.printError('simctl returned non-JSON response: ${results.stdout}');
      return <String, Object>{};
    }
  }

  /// Returns a list of all available devices, both potential and connected.
  Future<List<SimDevice>> getDevices() async {
    final List<SimDevice> devices = <SimDevice>[];

    final Map<String, Object?> devicesSection = await _list(SimControlListSection.devices);

    for (final String deviceCategory in devicesSection.keys) {
      final Object? devicesData = devicesSection[deviceCategory];
      if (devicesData != null && devicesData is List<Object?>) {
        for (final Map<String, Object?> data in devicesData.map<Map<String, Object?>?>(castStringKeyedMap).whereType<Map<String, Object?>>()) {
          devices.add(SimDevice(deviceCategory, data));
        }
      }
    }

    return devices;
  }

  /// Returns all the connected simulator devices.
  Future<List<SimDevice>> getConnectedDevices() async {
    final List<SimDevice> simDevices = await getDevices();
    return simDevices.where((SimDevice device) => device.isBooted).toList();
  }

  Future<bool> isInstalled(String deviceId, String appId) {
    return _processUtils.exitsHappy(<String>[
      ..._xcode.xcrunCommand(),
      'simctl',
      'get_app_container',
      deviceId,
      appId,
    ]);
  }

  Future<RunResult> install(String deviceId, String appPath) async {
    RunResult result;
    try {
      result = await _processUtils.run(
        <String>[
          ..._xcode.xcrunCommand(),
          'simctl',
          'install',
          deviceId,
          appPath,
        ],
        throwOnError: true,
      );
    } on ProcessException catch (exception) {
      throwToolExit('Unable to install $appPath on $deviceId. This is sometimes caused by a malformed plist file:\n$exception');
    }
    return result;
  }

  Future<RunResult> uninstall(String deviceId, String appId) async {
    RunResult result;
    try {
      result = await _processUtils.run(
        <String>[
          ..._xcode.xcrunCommand(),
          'simctl',
          'uninstall',
          deviceId,
          appId,
        ],
        throwOnError: true,
      );
    } on ProcessException catch (exception) {
      throwToolExit('Unable to uninstall $appId from $deviceId:\n$exception');
    }
    return result;
  }

  Future<RunResult> launch(String deviceId, String appIdentifier, [ List<String>? launchArgs ]) async {
    RunResult result;
    try {
      result = await _processUtils.run(
        <String>[
          ..._xcode.xcrunCommand(),
          'simctl',
          'launch',
          deviceId,
          appIdentifier,
          ...?launchArgs,
        ],
        throwOnError: true,
      );
    } on ProcessException catch (exception) {
      throwToolExit('Unable to launch $appIdentifier on $deviceId:\n$exception');
    }
    return result;
  }

  Future<void> takeScreenshot(String deviceId, String outputPath) async {
    try {
      await _processUtils.run(
        <String>[
          ..._xcode.xcrunCommand(),
          'simctl',
          'io',
          deviceId,
          'screenshot',
          outputPath,
        ],
        throwOnError: true,
      );
    } on ProcessException catch (exception) {
      _logger.printError('Unable to take screenshot of $deviceId:\n$exception');
    }
  }
}

/// Enumerates all data sections of `xcrun simctl list --json` command.
class SimControlListSection {
  const SimControlListSection._(this.name);

  final String name;

  static const SimControlListSection devices = SimControlListSection._('devices');
  static const SimControlListSection devicetypes = SimControlListSection._('devicetypes');
  static const SimControlListSection runtimes = SimControlListSection._('runtimes');
  static const SimControlListSection pairs = SimControlListSection._('pairs');
}

/// A simulated device type.
///
/// Simulated device types can be listed using the command
/// `xcrun simctl list devicetypes`.
class SimDeviceType {
  SimDeviceType(this.name, this.identifier);

  /// The name of the device type.
  ///
  /// Examples:
  ///
  ///     "iPhone 6s"
  ///     "iPhone 6 Plus"
  final String name;

  /// The identifier of the device type.
  ///
  /// Examples:
  ///
  ///     "com.apple.CoreSimulator.SimDeviceType.iPhone-6s"
  ///     "com.apple.CoreSimulator.SimDeviceType.iPhone-6-Plus"
  final String identifier;
}

class SimDevice {
  SimDevice(this.category, this.data);

  final String category;
  final Map<String, Object?> data;

  String? get state => data['state']?.toString();
  String? get availability => data['availability']?.toString();
  String? get name => data['name']?.toString();
  String? get udid => data['udid']?.toString();

  bool get isBooted => state == 'Booted';
}

class IOSSimulator extends Device {
  IOSSimulator(
    String id, {
      required this.name,
      required this.simulatorCategory,
      required SimControl simControl,
    }) : _simControl = simControl,
         super(
           id,
           category: Category.mobile,
           platformType: PlatformType.ios,
           ephemeral: true,
         );

  @override
  final String name;

  final String simulatorCategory;

  final SimControl _simControl;

  @override
  DevFSWriter createDevFSWriter(covariant ApplicationPackage app, String userIdentifier) {
    return LocalDevFSWriter(fileSystem: globals.fs);
  }

  @override
  Future<bool> get isLocalEmulator async => true;

  @override
  Future<String> get emulatorId async => iosSimulatorId;

  @override
  bool get supportsHotReload => true;

  @override
  bool get supportsHotRestart => true;

  @override
  Future<bool> get supportsHardwareRendering async => false;

  @override
  bool supportsRuntimeMode(BuildMode buildMode) => buildMode == BuildMode.debug;

  final Map<IOSApp?, DeviceLogReader> _logReaders = <IOSApp?, DeviceLogReader>{};
  _IOSSimulatorDevicePortForwarder? _portForwarder;

  @override
  Future<bool> isAppInstalled(
    ApplicationPackage app, {
    String? userIdentifier,
  }) {
    return _simControl.isInstalled(id, app.id);
  }

  @override
  Future<bool> isLatestBuildInstalled(ApplicationPackage app) async => false;

  @override
  Future<bool> installApp(
    covariant IOSApp app, {
    String? userIdentifier,
  }) async {
    try {
      final IOSApp iosApp = app;
      await _simControl.install(id, iosApp.simulatorBundlePath);
      return true;
    } on Exception {
      return false;
    }
  }

  @override
  Future<bool> uninstallApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async {
    try {
      await _simControl.uninstall(id, app.id);
      return true;
    } on Exception {
      return false;
    }
  }

  @override
  bool isSupported() {
    if (!globals.platform.isMacOS) {
      _supportMessage = 'iOS devices require a Mac host machine.';
      return false;
    }

    // Check if the device is part of a blocked category.
    // We do not yet support WatchOS or tvOS devices.
    final RegExp blocklist = RegExp(r'Apple (TV|Watch)', caseSensitive: false);
    if (blocklist.hasMatch(name)) {
      _supportMessage = 'Flutter does not support Apple TV or Apple Watch.';
      return false;
    }
    return true;
  }

  String? _supportMessage;

  @override
  String supportMessage() {
    if (isSupported()) {
      return 'Supported';
    }

    return _supportMessage ?? 'Unknown';
  }

  @override
  Future<LaunchResult> startApp(
    covariant IOSApp 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 (!prebuiltApplication && package is BuildableIOSApp) {
      globals.printTrace('Building ${package.name} for $id.');

      try {
        await _setupUpdatedApplicationBundle(package, debuggingOptions.buildInfo, mainPath);
      } on ToolExit catch (e) {
        globals.printError('${e.message}');
        return LaunchResult.failed();
      }
    } else {
      if (!await installApp(package)) {
        return LaunchResult.failed();
      }
    }

    // Prepare launch arguments.
    final String dartVmFlags = computeDartVmFlags(debuggingOptions);
    final List<String> args = <String>[
      '--enable-dart-profiling',
      if (debuggingOptions.debuggingEnabled) ...<String>[
        if (debuggingOptions.buildInfo.isDebug) ...<String>[
          '--enable-checked-mode',
          '--verify-entry-points',
        ],
        if (debuggingOptions.enableSoftwareRendering) '--enable-software-rendering',
        if (debuggingOptions.startPaused) '--start-paused',
        if (debuggingOptions.disableServiceAuthCodes) '--disable-service-auth-codes',
        if (debuggingOptions.skiaDeterministicRendering) '--skia-deterministic-rendering',
        if (debuggingOptions.useTestFonts) '--use-test-fonts',
        if (debuggingOptions.traceAllowlist != null) '--trace-allowlist="${debuggingOptions.traceAllowlist}"',
        if (debuggingOptions.traceSkiaAllowlist != null) '--trace-skia-allowlist="${debuggingOptions.traceSkiaAllowlist}"',
        if (dartVmFlags.isNotEmpty) '--dart-flags=$dartVmFlags',
        '--observatory-port=${debuggingOptions.hostVmServicePort ?? 0}'
      ],
    ];

    ProtocolDiscovery? observatoryDiscovery;
    if (debuggingOptions.debuggingEnabled) {
      observatoryDiscovery = ProtocolDiscovery.observatory(
        getLogReader(app: package),
        ipv6: ipv6,
        hostPort: debuggingOptions.hostVmServicePort,
        devicePort: debuggingOptions.deviceVmServicePort,
        logger: globals.logger,
      );
    }

    // Launch the updated application in the simulator.
    try {
      // Use the built application's Info.plist to get the bundle identifier,
      // which should always yield the correct value and does not require
      // parsing the xcodeproj or configuration files.
      // See https://github.com/flutter/flutter/issues/31037 for more information.
      final String plistPath = globals.fs.path.join(package.simulatorBundlePath, 'Info.plist');
      final String? bundleIdentifier = globals.plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleIdentifierKey);
      if (bundleIdentifier == null) {
        globals.printError('Invalid prebuilt iOS app. Info.plist does not contain bundle identifier');
        return LaunchResult.failed();
      }

      await _simControl.launch(id, bundleIdentifier, args);
    } on Exception catch (error) {
      globals.printError('$error');
      return LaunchResult.failed();
    }

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

    // Wait for the service protocol port here. This will complete once the
    // device has printed "Observatory is listening on..."
    globals.printTrace('Waiting for observatory port to be available...');

    try {
      final Uri? deviceUri = await observatoryDiscovery?.uri;
      if (deviceUri != null) {
        return LaunchResult.succeeded(observatoryUri: deviceUri);
      }
      globals.printError(
        'Error waiting for a debug connection: '
        'The log reader failed unexpectedly',
      );
    } on Exception catch (error) {
      globals.printError('Error waiting for a debug connection: $error');
    } finally {
      await observatoryDiscovery?.cancel();
    }
    return LaunchResult.failed();
  }

  Future<void> _setupUpdatedApplicationBundle(covariant BuildableIOSApp app, BuildInfo buildInfo, String? mainPath) async {
    // Step 1: Build the Xcode project.
    // The build mode for the simulator is always debug.
    assert(buildInfo.isDebug);

    final XcodeBuildResult buildResult = await buildXcodeProject(
      app: app,
      buildInfo: buildInfo,
      targetOverride: mainPath,
      environmentType: EnvironmentType.simulator,
      deviceID: id,
    );
    if (!buildResult.success) {
      throwToolExit('Could not build the application for the simulator.');
    }

    // Step 2: Assert that the Xcode project was successfully built.
    final Directory bundle = globals.fs.directory(app.simulatorBundlePath);
    final bool bundleExists = bundle.existsSync();
    if (!bundleExists) {
      throwToolExit('Could not find the built application bundle at ${bundle.path}.');
    }

    // Step 3: Install the updated bundle to the simulator.
    await _simControl.install(id, globals.fs.path.absolute(bundle.path));
  }

  @override
  Future<bool> stopApp(
    ApplicationPackage app, {
    String? userIdentifier,
  }) async {
    // Currently we don't have a way to stop an app running on iOS.
    return false;
  }

  String get logFilePath {
    final String? logPath = globals.platform.environment['IOS_SIMULATOR_LOG_FILE_PATH'];
    return logPath != null
      ? logPath.replaceAll('%{id}', id)
      : globals.fs.path.join(
          globals.fsUtils.homeDirPath!,
          'Library',
          'Logs',
          'CoreSimulator',
          id,
          'system.log',
        );
  }

  @override
  Future<TargetPlatform> get targetPlatform async => TargetPlatform.ios;

  @override
  Future<String> get sdkNameAndVersion async => simulatorCategory;

  final RegExp _iosSdkRegExp = RegExp(r'iOS( |-)(\d+)');

  Future<int> get sdkMajorVersion async {
    final Match? sdkMatch = _iosSdkRegExp.firstMatch(await sdkNameAndVersion);
    return int.parse(sdkMatch?.group(2) ?? '11');
  }

  @override
  DeviceLogReader getLogReader({
    IOSApp? app,
    bool includePastLogs = false,
  }) {
    assert(!includePastLogs, 'Past log reading not supported on iOS simulators.');
    return _logReaders.putIfAbsent(app, () => _IOSSimulatorLogReader(this, app));
  }

  @override
  DevicePortForwarder get portForwarder => _portForwarder ??= _IOSSimulatorDevicePortForwarder(this);

  @override
  void clearLogs() {
    final File logFile = globals.fs.file(logFilePath);
    if (logFile.existsSync()) {
      final RandomAccessFile randomFile = logFile.openSync(mode: FileMode.write);
      randomFile.truncateSync(0);
      randomFile.closeSync();
    }
  }

  Future<void> ensureLogsExists() async {
    if (await sdkMajorVersion < 11) {
      final File logFile = globals.fs.file(logFilePath);
      if (!logFile.existsSync()) {
        logFile.writeAsBytesSync(<int>[]);
      }
    }
  }

  @override
  bool get supportsScreenshot => true;

  @override
  Future<void> takeScreenshot(File outputFile) {
    return _simControl.takeScreenshot(id, outputFile.path);
  }

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

  @override
  Future<void> dispose() async {
    for (final DeviceLogReader logReader in _logReaders.values) {
      logReader.dispose();
    }
    await _portForwarder?.dispose();
  }
}

/// Launches the device log reader process on the host and parses the syslog.
@visibleForTesting
Future<Process> launchDeviceSystemLogTool(IOSSimulator device) async {
  return globals.processUtils.start(<String>['tail', '-n', '0', '-F', device.logFilePath]);
}

/// Launches the device log reader process on the host and parses unified logging.
@visibleForTesting
Future<Process> launchDeviceUnifiedLogging (IOSSimulator device, String? appName) async {
  // Make NSPredicate concatenation easier to read.
  String orP(List<String> clauses) => '(${clauses.join(" OR ")})';
  String andP(List<String> clauses) => clauses.join(' AND ');
  String notP(String clause) => 'NOT($clause)';

  final String predicate = andP(<String>[
    'eventType = logEvent',
    if (appName != null) 'processImagePath ENDSWITH "$appName"',
    // Either from Flutter or Swift (maybe assertion or fatal error) or from the app itself.
    orP(<String>[
      'senderImagePath ENDSWITH "/Flutter"',
      'senderImagePath ENDSWITH "/libswiftCore.dylib"',
      'processImageUUID == senderImageUUID',
    ]),
    // Filter out some messages that clearly aren't related to Flutter.
    notP('eventMessage CONTAINS ": could not find icon for representation -> com.apple."'),
    notP('eventMessage BEGINSWITH "assertion failed: "'),
    notP('eventMessage CONTAINS " libxpc.dylib "'),
  ]);

  return globals.processUtils.start(<String>[
    ...globals.xcode!.xcrunCommand(),
    'simctl',
    'spawn',
    device.id,
    'log',
    'stream',
    '--style',
    'json',
    '--predicate',
    predicate,
  ]);
}

@visibleForTesting
Future<Process?> launchSystemLogTool(IOSSimulator device) async {
  // Versions of iOS prior to 11 tail the simulator syslog file.
  if (await device.sdkMajorVersion < 11) {
    return globals.processUtils.start(<String>['tail', '-n', '0', '-F', '/private/var/log/system.log']);
  }

  // For iOS 11 and later, all relevant detail is in the device log.
  return null;
}

class _IOSSimulatorLogReader extends DeviceLogReader {
  _IOSSimulatorLogReader(this.device, IOSApp? app) : _appName = app?.name?.replaceAll('.app', '');

  final IOSSimulator device;

  final String? _appName;

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

  // We log from two files: the device and the system log.
  Process? _deviceProcess;
  Process? _systemProcess;

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

  @override
  String get name => device.name;

  Future<void> _start() async {
    // Unified logging iOS 11 and greater (introduced in iOS 10).
    if (await device.sdkMajorVersion >= 11) {
      _deviceProcess = await launchDeviceUnifiedLogging(device, _appName);
      _deviceProcess?.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onUnifiedLoggingLine);
      _deviceProcess?.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onUnifiedLoggingLine);
    } else {
      // Fall back to syslog parsing.
      await device.ensureLogsExists();
      _deviceProcess = await launchDeviceSystemLogTool(device);
      _deviceProcess?.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSysLogDeviceLine);
      _deviceProcess?.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSysLogDeviceLine);
    }

    // Track system.log crashes.
    // ReportCrash[37965]: Saved crash report for FlutterRunner[37941]...
    _systemProcess = await launchSystemLogTool(device);
    if (_systemProcess != null) {
      _systemProcess?.stdout.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSystemLine);
      _systemProcess?.stderr.transform<String>(utf8.decoder).transform<String>(const LineSplitter()).listen(_onSystemLine);
    }

    // We don't want to wait for the process or its callback. Best effort
    // cleanup in the callback.
    unawaited(_deviceProcess?.exitCode.whenComplete(() {
      if (_linesController.hasListener) {
        _linesController.close();
      }
    }));
  }

  // Match the log prefix (in order to shorten it):
  // * Xcode 8: Sep 13 15:28:51 cbracken-macpro localhost Runner[37195]: (Flutter) Observatory listening on http://127.0.0.1:57701/
  // * Xcode 9: 2017-09-13 15:26:57.228948-0700  localhost Runner[37195]: (Flutter) Observatory listening on http://127.0.0.1:57701/
  static final RegExp _mapRegex = RegExp(r'\S+ +\S+ +(?:\S+) (.+?(?=\[))\[\d+\]\)?: (\(.*?\))? *(.*)$');

  // Jan 31 19:23:28 --- last message repeated 1 time ---
  static final RegExp _lastMessageSingleRegex = RegExp(r'\S+ +\S+ +\S+ --- last message repeated 1 time ---$');
  static final RegExp _lastMessageMultipleRegex = RegExp(r'\S+ +\S+ +\S+ --- last message repeated (\d+) times ---$');

  static final RegExp _flutterRunnerRegex = RegExp(r' FlutterRunner\[\d+\] ');

  // Remember what we did with the last line, in case we need to process
  // a multiline record
  bool _lastLineMatched = false;

  String? _filterDeviceLine(String string) {
    final Match? match = _mapRegex.matchAsPrefix(string);
    if (match != null) {

      // The category contains the text between the date and the PID. Depending on which version of iOS being run,
      // it can contain "hostname App Name" or just "App Name".
      final String? category = match.group(1);
      final String? tag = match.group(2);
      final String? content = match.group(3);

      // Filter out log lines from an app other than this one (category doesn't match the app name).
      // If the hostname is included in the category, check that it doesn't end with the app name.
      final String? appName = _appName;
      if (appName != null && category != null && !category.endsWith(appName)) {
        return null;
      }

      if (tag != null && tag != '(Flutter)') {
        return null;
      }

      // Filter out some messages that clearly aren't related to Flutter.
      if (string.contains(': could not find icon for representation -> com.apple.')) {
        return null;
      }

      // assertion failed: 15G1212 13E230: libxpc.dylib + 57882 [66C28065-C9DB-3C8E-926F-5A40210A6D1B]: 0x7d
      if (content != null && content.startsWith('assertion failed: ') && content.contains(' libxpc.dylib ')) {
        return null;
      }

      if (appName == null) {
        return '$category: $content';
      } else if (category != null && (category == appName || category.endsWith(' $appName'))) {
        return content;
      }

      return null;
    }

    if (string.startsWith('Filtering the log data using ')) {
      return null;
    }

    if (string.startsWith('Timestamp                       (process)[PID]')) {
      return null;
    }

    if (_lastMessageSingleRegex.matchAsPrefix(string) != null) {
      return null;
    }

    if (RegExp(r'assertion failed: .* libxpc.dylib .* 0x7d$').matchAsPrefix(string) != null) {
      return null;
    }

    // Starts with space(s) - continuation of the multiline message
    if (RegExp(r'\s+').matchAsPrefix(string) != null && !_lastLineMatched) {
      return null;
    }

    return string;
  }

  String? _lastLine;

  void _onSysLogDeviceLine(String line) {
    globals.printTrace('[DEVICE LOG] $line');
    final Match? multi = _lastMessageMultipleRegex.matchAsPrefix(line);

    if (multi != null) {
      if (_lastLine != null) {
        int repeat = int.parse(multi.group(1)!);
        repeat = math.max(0, math.min(100, repeat));
        for (int i = 1; i < repeat; i++) {
          _linesController.add(_lastLine!);
        }
      }
    } else {
      _lastLine = _filterDeviceLine(line);
      if (_lastLine != null) {
        _linesController.add(_lastLine!);
        _lastLineMatched = true;
      } else {
        _lastLineMatched = false;
      }
    }
  }

  //   "eventMessage" : "flutter: 21",
  static final RegExp _unifiedLoggingEventMessageRegex = RegExp(r'.*"eventMessage" : (".*")');
  void _onUnifiedLoggingLine(String line) {
    // The log command predicate handles filtering, so every log eventMessage should be decoded and added.
    final Match? eventMessageMatch = _unifiedLoggingEventMessageRegex.firstMatch(line);
    if (eventMessageMatch != null) {
      final String message = eventMessageMatch.group(1)!;
      try {
        final Object? decodedJson = jsonDecode(message);
        if (decodedJson is String) {
          _linesController.add(decodedJson);
        }
      } on FormatException {
        globals.printError('Logger returned non-JSON response: $message');
      }
    }
  }

  String _filterSystemLog(String string) {
    final Match? match = _mapRegex.matchAsPrefix(string);
    return match == null ? string : '${match.group(1)}: ${match.group(2)}';
  }

  void _onSystemLine(String line) {
    globals.printTrace('[SYS LOG] $line');
    if (!_flutterRunnerRegex.hasMatch(line)) {
      return;
    }

    final String filteredLine = _filterSystemLog(line);
    if (filteredLine == null) {
      return;
    }

    _linesController.add(filteredLine);
  }

  void _stop() {
    _deviceProcess?.kill();
    _systemProcess?.kill();
  }

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

int compareIosVersions(String v1, String v2) {
  final List<int> v1Fragments = v1.split('.').map<int>(int.parse).toList();
  final List<int> v2Fragments = v2.split('.').map<int>(int.parse).toList();

  int i = 0;
  while (i < v1Fragments.length && i < v2Fragments.length) {
    final int v1Fragment = v1Fragments[i];
    final int v2Fragment = v2Fragments[i];
    if (v1Fragment != v2Fragment) {
      return v1Fragment.compareTo(v2Fragment);
    }
    i += 1;
  }
  return v1Fragments.length.compareTo(v2Fragments.length);
}

class _IOSSimulatorDevicePortForwarder extends DevicePortForwarder {
  _IOSSimulatorDevicePortForwarder(this.device);

  final IOSSimulator device;

  final List<ForwardedPort> _ports = <ForwardedPort>[];

  @override
  List<ForwardedPort> get forwardedPorts => _ports;

  @override
  Future<int> forward(int devicePort, { int? hostPort }) async {
    if (hostPort == null || hostPort == 0) {
      hostPort = devicePort;
    }
    assert(devicePort == hostPort);
    _ports.add(ForwardedPort(devicePort, hostPort));
    return hostPort;
  }

  @override
  Future<void> unforward(ForwardedPort forwardedPort) async {
    _ports.remove(forwardedPort);
  }

  @override
  Future<void> dispose() async {
    final List<ForwardedPort> portsCopy = List<ForwardedPort>.of(_ports);
    for (final ForwardedPort port in portsCopy) {
      await unforward(port);
    }
  }
}
