// 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.

/// @docImport 'dart:ui';
///
/// @docImport 'package:flutter/widgets.dart';
///
/// @docImport 'system_chrome.dart';
library;

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui' as ui;

import 'package:flutter/foundation.dart';
import 'package:flutter/scheduler.dart';

import 'asset_bundle.dart';
import 'binary_messenger.dart';
import 'debug.dart';
import 'hardware_keyboard.dart';
import 'message_codec.dart';
import 'platform_channel.dart';
import 'raw_keyboard.dart' show RawKeyboard;
import 'restoration.dart';
import 'service_extensions.dart';
import 'system_channels.dart';
import 'system_chrome.dart';
import 'text_input.dart';

export 'dart:ui' show ChannelBuffers, RootIsolateToken;

export 'binary_messenger.dart' show BinaryMessenger;
export 'hardware_keyboard.dart' show HardwareKeyboard, KeyEventManager;
export 'restoration.dart' show RestorationManager;

/// Listens for platform messages and directs them to the [defaultBinaryMessenger].
///
/// The [ServicesBinding] also registers a [LicenseEntryCollector] that exposes
/// the licenses found in the `LICENSE` file stored at the root of the asset
/// bundle, and implements the `ext.flutter.evict` service extension (see
/// [evict]).
mixin ServicesBinding on BindingBase, SchedulerBinding {
  @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    _defaultBinaryMessenger = createBinaryMessenger();
    _restorationManager = createRestorationManager();
    _initKeyboard();
    initLicenses();
    SystemChannels.system.setMessageHandler(
      (dynamic message) => handleSystemMessage(message as Object),
    );
    SystemChannels.accessibility.setMessageHandler(
      (dynamic message) => _handleAccessibilityMessage(message as Object),
    );
    SystemChannels.lifecycle.setMessageHandler(_handleLifecycleMessage);
    SystemChannels.platform.setMethodCallHandler(_handlePlatformMessage);
    platformDispatcher.onViewFocusChange = handleViewFocusChanged;
    TextInput.ensureInitialized();
    readInitialLifecycleStateFromNativeWindow();
    initializationComplete();
  }

  /// The current [ServicesBinding], if one has been created.
  ///
  /// Provides access to the features exposed by this mixin. The binding must
  /// be initialized before using this getter; this is typically done by calling
  /// [runApp] or [WidgetsFlutterBinding.ensureInitialized].
  static ServicesBinding get instance => BindingBase.checkInstance(_instance);
  static ServicesBinding? _instance;

  /// The global singleton instance of [HardwareKeyboard], which can be used to
  /// query keyboard states.
  HardwareKeyboard get keyboard => _keyboard;
  late final HardwareKeyboard _keyboard;

  /// The global singleton instance of [KeyEventManager], which is used
  /// internally to dispatch key messages.
  ///
  /// This property is deprecated, and will be removed. See
  /// [HardwareKeyboard.addHandler] instead.
  @Deprecated(
    'No longer supported. Add a handler to HardwareKeyboard instead. '
    'This feature was deprecated after v3.18.0-2.0.pre.',
  )
  KeyEventManager get keyEventManager => _keyEventManager;
  late final KeyEventManager _keyEventManager;

  void _initKeyboard() {
    _keyboard = HardwareKeyboard();
    _keyEventManager = KeyEventManager(_keyboard, RawKeyboard.instance);
    _keyboard.syncKeyboardState().then((_) {
      platformDispatcher.onKeyData = _keyEventManager.handleKeyData;
      SystemChannels.keyEvent.setMessageHandler(_keyEventManager.handleRawKeyMessage);
    });
  }

  /// The default instance of [BinaryMessenger].
  ///
  /// This is used to send messages from the application to the platform, and
  /// keeps track of which handlers have been registered on each channel so
  /// it may dispatch incoming messages to the registered handler.
  ///
  /// The default implementation returns a [BinaryMessenger] that delivers the
  /// messages in the same order in which they are sent.
  BinaryMessenger get defaultBinaryMessenger => _defaultBinaryMessenger;
  late final BinaryMessenger _defaultBinaryMessenger;

  /// A token that represents the root isolate, used for coordinating with
  /// background isolates.
  ///
  /// This property is primarily intended for use with
  /// [BackgroundIsolateBinaryMessenger.ensureInitialized], which takes a
  /// [RootIsolateToken] as its argument. The value `null` is returned when
  /// executed from background isolates.
  static ui.RootIsolateToken? get rootIsolateToken => ui.RootIsolateToken.instance;

  /// The low level buffering and dispatch mechanism for messages sent by
  /// plugins on the engine side to their corresponding plugin code on
  /// the framework side.
  ///
  /// This exposes the [dart:ui.channelBuffers] object. Bindings can override
  /// this getter to intercept calls to the [ChannelBuffers] mechanism (for
  /// example, for tests).
  ///
  /// In production, direct access to this object should not be necessary.
  /// Messages are received and dispatched by the [defaultBinaryMessenger]. This
  /// object is primarily used to send mock messages in tests, via the
  /// [ChannelBuffers.push] method (simulating a plugin sending a message to the
  /// framework).
  ///
  /// See also:
  ///
  ///  * [PlatformDispatcher.sendPlatformMessage], which is used for sending
  ///    messages to plugins from the framework (the opposite of
  ///    [channelBuffers]).
  ///  * [platformDispatcher], the [PlatformDispatcher] singleton.
  ui.ChannelBuffers get channelBuffers => ui.channelBuffers;

  /// Creates a default [BinaryMessenger] instance that can be used for sending
  /// platform messages.
  ///
  /// Many Flutter framework components that communicate with the platform
  /// assume messages are received by the platform in the same order in which
  /// they are sent. When overriding this method, be sure the [BinaryMessenger]
  /// implementation guarantees FIFO delivery.
  @protected
  BinaryMessenger createBinaryMessenger() {
    return const _DefaultBinaryMessenger._();
  }

  /// Called when the operating system notifies the application of a memory
  /// pressure situation.
  ///
  /// This method exposes the `memoryPressure` notification from
  /// [SystemChannels.system].
  @protected
  @mustCallSuper
  void handleMemoryPressure() {
    rootBundle.clear();
  }

  /// Handler called for messages received on the [SystemChannels.system]
  /// message channel.
  ///
  /// Other bindings may override this to respond to incoming system messages.
  @protected
  @mustCallSuper
  Future<void> handleSystemMessage(Object systemMessage) async {
    final message = systemMessage as Map<String, dynamic>;
    final type = message['type'] as String;
    switch (type) {
      case 'memoryPressure':
        handleMemoryPressure();
    }
    return;
  }

  /// Adds relevant licenses to the [LicenseRegistry].
  ///
  /// By default, the [ServicesBinding]'s implementation of [initLicenses] adds
  /// all the licenses collected by the `flutter` tool during compilation.
  @protected
  @mustCallSuper
  void initLicenses() {
    LicenseRegistry.addLicense(_addLicenses);
  }

  Stream<LicenseEntry> _addLicenses() {
    late final StreamController<LicenseEntry> controller;
    controller = StreamController<LicenseEntry>(
      onListen: () async {
        late final String rawLicenses;
        if (kIsWeb) {
          // NOTICES for web isn't compressed since we don't have access to
          // dart:io on the client side and it's already compressed between
          // the server and client.
          rawLicenses = await rootBundle.loadString('NOTICES', cache: false);
        } else {
          // The compressed version doesn't have a more common .gz extension
          // because gradle for Android non-transparently manipulates .gz files.
          final ByteData licenseBytes = await rootBundle.load('NOTICES.Z');
          final List<int> unzippedBytes = await compute<List<int>, List<int>>(
            gzip.decode,
            licenseBytes.buffer.asUint8List(),
            debugLabel: 'decompressLicenses',
          );
          rawLicenses = await compute<List<int>, String>(
            utf8.decode,
            unzippedBytes,
            debugLabel: 'utf8DecodeLicenses',
          );
        }
        final List<LicenseEntry> licenses = await compute<String, List<LicenseEntry>>(
          _parseLicenses,
          rawLicenses,
          debugLabel: 'parseLicenses',
        );
        licenses.forEach(controller.add);
        await controller.close();
      },
    );
    return controller.stream;
  }

  // This is run in another isolate created by _addLicenses above.
  static List<LicenseEntry> _parseLicenses(String rawLicenses) {
    final licenseSeparator = '\n${'-' * 80}\n';
    return <LicenseEntry>[
      for (final String license in rawLicenses.split(licenseSeparator))
        if (license.indexOf('\n\n') case final int split when split >= 0)
          LicenseEntryWithLineBreaks(
            license.substring(0, split).split('\n'),
            license.substring(split + 2),
          )
        else
          LicenseEntryWithLineBreaks(const <String>[], license),
    ];
  }

  @override
  void initServiceExtensions() {
    super.initServiceExtensions();

    assert(() {
      registerStringServiceExtension(
        name: ServicesServiceExtensions.evict.name,
        getter: () async => '',
        setter: (String value) async {
          evict(value);
        },
      );
      return true;
    }());

    if (!kReleaseMode) {
      registerBoolServiceExtension(
        name: ServicesServiceExtensions.profilePlatformChannels.name,
        getter: () async => debugProfilePlatformChannels,
        setter: (bool value) async {
          debugProfilePlatformChannels = value;
        },
      );
    }
  }

  /// Called in response to the `ext.flutter.evict` service extension.
  ///
  /// This is used by the `flutter` tool during hot reload so that any images
  /// that have changed on disk get cleared from caches.
  @protected
  @mustCallSuper
  void evict(String asset) {
    rootBundle.evict(asset);
  }

  // App life cycle

  /// Initializes the [lifecycleState] with the
  /// [dart:ui.PlatformDispatcher.initialLifecycleState].
  ///
  /// Once the [lifecycleState] is populated through any means (including this
  /// method), this method will do nothing. This is because the
  /// [dart:ui.PlatformDispatcher.initialLifecycleState] may already be stale
  /// and it no longer makes sense to use the initial state at dart vm startup
  /// as the current state anymore.
  ///
  /// The latest state should be obtained by subscribing to
  /// [WidgetsBindingObserver.didChangeAppLifecycleState].
  @protected
  void readInitialLifecycleStateFromNativeWindow() {
    if (lifecycleState != null || platformDispatcher.initialLifecycleState.isEmpty) {
      return;
    }
    _handleLifecycleMessage(platformDispatcher.initialLifecycleState);
  }

  Future<String?> _handleLifecycleMessage(String? message) async {
    final AppLifecycleState? state = _parseAppLifecycleMessage(message!);
    final List<AppLifecycleState> generated = _generateStateTransitions(lifecycleState, state!);
    for (final stateChange in generated) {
      handleAppLifecycleStateChanged(stateChange);
      SystemChrome.handleAppLifecycleStateChanged(stateChange);
    }
    return null;
  }

  List<AppLifecycleState> _generateStateTransitions(
    AppLifecycleState? previousState,
    AppLifecycleState state,
  ) {
    if (previousState == state) {
      return const <AppLifecycleState>[];
    }
    final stateChanges = <AppLifecycleState>[];
    if (previousState == null) {
      // If there was no previous state, just jump directly to the new state.
      stateChanges.add(state);
    } else {
      final int previousStateIndex = AppLifecycleState.values.indexOf(previousState);
      final int stateIndex = AppLifecycleState.values.indexOf(state);
      assert(previousStateIndex != -1, 'State $previousState missing in stateOrder array');
      assert(stateIndex != -1, 'State $state missing in stateOrder array');
      if (state == AppLifecycleState.detached) {
        for (int i = previousStateIndex + 1; i < AppLifecycleState.values.length; ++i) {
          stateChanges.add(AppLifecycleState.values[i]);
        }
        stateChanges.add(AppLifecycleState.detached);
      } else if (previousStateIndex > stateIndex) {
        for (var i = stateIndex; i < previousStateIndex; ++i) {
          stateChanges.insert(0, AppLifecycleState.values[i]);
        }
      } else {
        for (int i = previousStateIndex + 1; i <= stateIndex; ++i) {
          stateChanges.add(AppLifecycleState.values[i]);
        }
      }
    }
    assert(
      () {
        var starting = previousState;
        for (final ending in stateChanges) {
          if (!_debugVerifyLifecycleChange(starting, ending)) {
            return false;
          }
          starting = ending;
        }
        return true;
      }(),
      'Invalid lifecycle state transition generated from $previousState to $state (generated $stateChanges)',
    );
    return stateChanges;
  }

  static bool _debugVerifyLifecycleChange(AppLifecycleState? starting, AppLifecycleState ending) {
    if (starting == null) {
      // Any transition from null is fine, since it is initializing the state.
      return true;
    }
    if (starting == ending) {
      // Any transition to itself shouldn't happen.
      return false;
    }
    return switch (starting) {
      // Can't go from resumed to detached directly (must go through paused).
      AppLifecycleState.resumed => ending == AppLifecycleState.inactive,
      AppLifecycleState.detached =>
        ending == AppLifecycleState.resumed || ending == AppLifecycleState.paused,
      AppLifecycleState.inactive =>
        ending == AppLifecycleState.resumed || ending == AppLifecycleState.hidden,
      AppLifecycleState.hidden =>
        ending == AppLifecycleState.paused || ending == AppLifecycleState.inactive,
      AppLifecycleState.paused =>
        ending == AppLifecycleState.hidden || ending == AppLifecycleState.detached,
    };
  }

  /// Listenable that notifies when the accessibility focus on the system have changed.
  final ValueNotifier<int?> accessibilityFocus = ValueNotifier<int?>(null);

  Future<void> _handleAccessibilityMessage(Object accessibilityMessage) async {
    final Map<String, dynamic> message = (accessibilityMessage as Map<Object?, Object?>)
        .cast<String, dynamic>();
    final type = message['type'] as String;
    switch (type) {
      case 'didGainFocus':
        accessibilityFocus.value = message['nodeId'] as int;
    }
    return;
  }

  /// Called whenever the [PlatformDispatcher] receives a notification that the
  /// focus state on a view has changed.
  ///
  /// The [event] contains the view ID for the view that changed its focus
  /// state.
  ///
  /// See also:
  ///
  /// * [PlatformDispatcher.onViewFocusChange], which calls this method.
  @protected
  @mustCallSuper
  void handleViewFocusChanged(ui.ViewFocusEvent event) {}

  Future<dynamic> _handlePlatformMessage(MethodCall methodCall) async {
    final String method = methodCall.method;
    switch (method) {
      // Called when the system dismisses the system context menu, such as when
      // the user taps outside the menu. Not called when Flutter shows a new
      // system context menu while an old one is still visible.
      case 'ContextMenu.onDismissSystemContextMenu':
        if (_systemContextMenuClient == null) {
          assert(
            false,
            'Platform sent onDismissSystemContextMenu when no SystemContextMenuClient was registered.',
          );
          return;
        }
        _systemContextMenuClient!.handleSystemHide();
        _systemContextMenuClient = null;
      case 'ContextMenu.onPerformCustomAction':
        if (_systemContextMenuClient == null) {
          assert(
            false,
            'Platform sent onPerformCustomAction when no SystemContextMenuClient was registered. '
            "ServicesBinding.systemContextMenuClient shouldn't be cleared unless the menu is hidden.",
          );
          return;
        }
        final args = methodCall.arguments as List<dynamic>;
        final callbackId = args[1] as String;
        _systemContextMenuClient!.handleCustomContextMenuAction(callbackId);
      case 'SystemChrome.systemUIChange':
        final args = methodCall.arguments as List<dynamic>;
        await _systemUiChangeCallback?.call(args[0] as bool);
      case 'System.requestAppExit':
        return <String, dynamic>{'response': (await handleRequestAppExit()).name};
      default:
        throw AssertionError('Method "$method" not handled.');
    }
  }

  static AppLifecycleState? _parseAppLifecycleMessage(String message) {
    return switch (message) {
      'AppLifecycleState.resumed' => AppLifecycleState.resumed,
      'AppLifecycleState.inactive' => AppLifecycleState.inactive,
      'AppLifecycleState.hidden' => AppLifecycleState.hidden,
      'AppLifecycleState.paused' => AppLifecycleState.paused,
      'AppLifecycleState.detached' => AppLifecycleState.detached,
      _ => null,
    };
  }

  /// Handles any requests for application exit that may be received on the
  /// [SystemChannels.platform] method channel.
  ///
  /// By default, returns [ui.AppExitResponse.exit].
  ///
  /// {@template flutter.services.binding.ServicesBinding.requestAppExit}
  /// Not all exits are cancelable, so not all exits will call this function. Do
  /// not rely on this function as a place to save critical data, because you
  /// will be disappointed. There are a number of ways that the application can
  /// exit without letting the application know first: power can be unplugged,
  /// the battery removed, the application can be killed in a task manager or
  /// command line, or the device could have a rapid unplanned disassembly (i.e.
  /// it could explode). In all of those cases (and probably others), no
  /// notification will be given to the application that it is about to exit.
  /// {@endtemplate}
  ///
  /// {@tool sample}
  /// This examples shows how an application can cancel (or not) OS requests for
  /// quitting an application. Currently this is only supported on macOS and
  /// Linux.
  ///
  /// ** See code in examples/api/lib/services/binding/handle_request_app_exit.0.dart **
  /// {@end-tool}
  ///
  /// See also:
  ///
  /// * [WidgetsBindingObserver.didRequestAppExit], which can be overridden to
  ///   respond to this message.
  /// * [WidgetsBinding.handleRequestAppExit] which overrides this method to
  ///   notify its observers.
  Future<ui.AppExitResponse> handleRequestAppExit() async {
    return ui.AppExitResponse.exit;
  }

  /// Exits the application by calling the native application API method for
  /// exiting an application cleanly.
  ///
  /// This differs from calling `dart:io`'s [exit] function in that it gives the
  /// engine a chance to clean up resources so that it doesn't crash on exit, so
  /// calling this is always preferred over calling [exit]. It also optionally
  /// gives handlers of [handleRequestAppExit] a chance to cancel the
  /// application exit.
  ///
  /// The [exitType] indicates what kind of exit to perform. For
  /// [ui.AppExitType.cancelable] exits, the application is queried through a
  /// call to [handleRequestAppExit], where the application can optionally
  /// cancel the request for exit. If the [exitType] is
  /// [ui.AppExitType.required], then the application exits immediately without
  /// querying the application.
  ///
  /// For [ui.AppExitType.cancelable] exits, the returned response value is the
  /// response obtained from the application as to whether the exit was canceled
  /// or not. Practically, the response will never be [ui.AppExitResponse.exit],
  /// since the application will have already exited by the time the result
  /// would have been received.
  ///
  /// The optional [exitCode] argument will be used as the application exit code
  /// on platforms where an exit code is supported. On other platforms it may be
  /// ignored. It defaults to zero.
  ///
  /// See also:
  ///
  /// * [WidgetsBindingObserver.didRequestAppExit] for a handler you can
  ///   override on a [WidgetsBindingObserver] to receive exit requests.
  Future<ui.AppExitResponse> exitApplication(ui.AppExitType exitType, [int exitCode = 0]) async {
    final Map<String, Object?>? result = await SystemChannels.platform
        .invokeMethod<Map<String, Object?>>('System.exitApplication', <String, Object?>{
          'type': exitType.name,
          'exitCode': exitCode,
        });
    if (result == null) {
      return ui.AppExitResponse.cancel;
    }
    switch (result['response']) {
      case 'cancel':
        return ui.AppExitResponse.cancel;
      case 'exit':
      default:
        // In practice, this will never get returned, because the application
        // will have exited before it returns.
        return ui.AppExitResponse.exit;
    }
  }

  /// The [RestorationManager] synchronizes the restoration data between
  /// engine and framework.
  ///
  /// See the docs for [RestorationManager] for a discussion of restoration
  /// state and how it is organized in Flutter.
  ///
  /// To use a different [RestorationManager] subclasses can override
  /// [createRestorationManager], which is called to create the instance
  /// returned by this getter.
  RestorationManager get restorationManager => _restorationManager;
  late RestorationManager _restorationManager;

  /// Creates the [RestorationManager] instance available via
  /// [restorationManager].
  ///
  /// Can be overridden in subclasses to create a different [RestorationManager].
  @protected
  RestorationManager createRestorationManager() {
    return RestorationManager();
  }

  SystemUiChangeCallback? _systemUiChangeCallback;

  /// Sets the callback for the `SystemChrome.systemUIChange` method call
  /// received on the [SystemChannels.platform] channel.
  ///
  /// This is typically not called directly. System UI changes that this method
  /// responds to are associated with [SystemUiMode]s, which are configured
  /// using [SystemChrome]. Use [SystemChrome.setSystemUIChangeCallback] to configure
  /// along with other SystemChrome settings.
  ///
  /// See also:
  ///
  ///   * [SystemChrome.setEnabledSystemUIMode], which specifies the
  ///     [SystemUiMode] to have visible when the application is running.
  // ignore: use_setters_to_change_properties, (API predates enforcing the lint)
  void setSystemUiChangeCallback(SystemUiChangeCallback? callback) {
    _systemUiChangeCallback = callback;
  }

  /// Alert the engine that the binding is registered. This instructs the engine to
  /// register its top level window handler on Windows. This signals that the app
  /// is able to process "System.requestAppExit" signals from the engine.
  @protected
  Future<void> initializationComplete() async {
    await SystemChannels.platform.invokeMethod('System.initializationComplete');
  }

  SystemContextMenuClient? _systemContextMenuClient;

  /// Registers a [SystemContextMenuClient] that will receive system context
  /// menu calls from the engine.
  ///
  /// To unregister, set to null.
  static set systemContextMenuClient(SystemContextMenuClient? client) {
    instance._systemContextMenuClient = client;
  }
}

/// Signature for listening to changes in the [SystemUiMode].
///
/// Set by [SystemChrome.setSystemUIChangeCallback].
typedef SystemUiChangeCallback = Future<void> Function(bool systemOverlaysAreVisible);

/// The default implementation of [BinaryMessenger].
///
/// This messenger sends messages from the app-side to the platform-side and
/// dispatches incoming messages from the platform-side to the appropriate
/// handler.
class _DefaultBinaryMessenger extends BinaryMessenger {
  const _DefaultBinaryMessenger._();

  @override
  Future<void> handlePlatformMessage(
    String channel,
    ByteData? message,
    ui.PlatformMessageResponseCallback? callback,
  ) async {
    ui.channelBuffers.push(channel, message, (ByteData? data) => callback?.call(data));
  }

  @override
  Future<ByteData?> send(String channel, ByteData? message) {
    final completer = Completer<ByteData?>();
    // ui.PlatformDispatcher.instance is accessed directly instead of using
    // ServicesBinding.instance.platformDispatcher because this method might be
    // invoked before any binding is initialized. This issue was reported in
    // #27541. It is not ideal to statically access
    // ui.PlatformDispatcher.instance because the PlatformDispatcher may be
    // dependency injected elsewhere with a different instance. However, static
    // access at this location seems to be the least bad option.
    // TODO(ianh): Use ServicesBinding.instance once we have better diagnostics
    // on that getter.
    ui.PlatformDispatcher.instance.sendPlatformMessage(channel, message, (ByteData? reply) {
      try {
        completer.complete(reply);
      } catch (exception, stack) {
        FlutterError.reportError(
          FlutterErrorDetails(
            exception: exception,
            stack: stack,
            library: 'services library',
            context: ErrorDescription('during a platform message response callback'),
          ),
        );
      }
    });
    return completer.future;
  }

  @override
  void setMessageHandler(String channel, MessageHandler? handler) {
    if (handler == null) {
      ui.channelBuffers.clearListener(channel);
    } else {
      ui.channelBuffers.setListener(channel, (
        ByteData? data,
        ui.PlatformMessageResponseCallback callback,
      ) async {
        ByteData? response;
        try {
          response = await handler(data);
        } catch (exception, stack) {
          FlutterError.reportError(
            FlutterErrorDetails(
              exception: exception,
              stack: stack,
              library: 'services library',
              context: ErrorDescription('during a platform message callback'),
            ),
          );
        } finally {
          callback(response);
        }
      });
    }
  }
}

/// An interface to receive calls related to the system context menu from the
/// engine.
///
/// Currently this is only supported on iOS 16+.
///
/// See also:
///  * [SystemContextMenuController], which uses this to provide a fully
///    featured way to control the system context menu.
///  * [ServicesBinding.systemContextMenuClient], which can be set to a
///    [SystemContextMenuClient] to register it to receive events, or null to
///    unregister.
///  * [MediaQuery.maybeSupportsShowingSystemContextMenu], which indicates
///    whether the system context menu is supported.
///  * [SystemContextMenu], which provides a widget interface for displaying the
///    system context menu.
mixin SystemContextMenuClient {
  /// Handles the system hiding a context menu.
  ///
  /// Called only on the single active instance registered with
  /// [ServicesBinding.systemContextMenuClient].
  void handleSystemHide();

  /// Called when a custom context menu action is triggered.
  void handleCustomContextMenuAction(String actionId);
}
