阅读 520

Flutter输入框获取剪切板-合规问题踩坑

前言:公司法务部检测出Flutter开发的App存在未同意隐私协议先获取系统剪切板数据的问题,要求整改。经过一系列调试后,定位到原来是Flutter输入框的坑,只要使用到输入框,就会先获取下剪切板数据。还没有属性可以关闭,着实踩坑,以下记录分享给大家,希望能稳稳避坑......

合规问题-获取剪切板数据

这个问题首次出现其实是在去年iOS14上线直接把app应用获取剪贴板内容的行为直接暴露出来。2020年6月29日,抖音海外版TikTok因为频繁读取用户剪贴板内容引争议,甚至被作为后面将其驱逐出海外市场的导火索。 国内监管部门虽然并没有明确的对访问剪贴板内容的直接要求,但是随着近年来社会上对隐私保护的重视和媒体关注,接下来会有发酵可能。

获取剪切板内容的应用场景

目前国内剪切板内容主要应用场景是类似淘口令之类的方式,通过读取剪切板的内容,弹出对应的内容;更有甚者,采集用户剪切板数据进行大数据分析,因为用户复制的内容,具备极高的用户兴趣导向,作为大数据训练素材准确性很高。 而Flutter输入框为何也获取剪切板内容,有留意过长按输入框的交互吗? 长按会有toolbar提供粘贴、复制等功能,而粘贴就必须先获取剪切板的内容。 然后基本上App的登录页都有输入框,只要你在用户同意隐私协议之前,显示了Flutter中的TextField,就必然会触发这个潜在的合规问题。 🐶

Flutter输入框是如何获取剪切板数据的

这个问题需要我们一步步来跟踪源码。

  1. 首先看TextField的源码,有一个属性enableInteractiveSelection,可以理解为启用交互式选择。从业务逻辑出发,把这个属性设为false,应该就不会出现toolbar了,那应该不需要获取剪切板数据以提供粘贴功能。
/// text_field.dart
/// TextField的常量构造函数
const TextField({
    Key? key,
    this.controller,
    this.focusNode,
    this.decoration = const InputDecoration(),
    TextInputType? keyboardType,
    this.textInputAction,
    this.textCapitalization = TextCapitalization.none,
    this.style,
    this.strutStyle,
    this.textAlign = TextAlign.start,
    this.textAlignVertical,
    this.textDirection,
    this.readOnly = false,
    ToolbarOptions? toolbarOptions,
    this.showCursor,
    this.autofocus = false,
    this.obscuringCharacter = '•',
    this.obscureText = false,
    this.autocorrect = true,
    SmartDashesType? smartDashesType,
    SmartQuotesType? smartQuotesType,
    this.enableSuggestions = true,
    this.maxLines = 1,
    this.minLines,
    this.expands = false,
    this.maxLength,
    @Deprecated(
      'Use maxLengthEnforcement parameter which provides more specific '
      'behavior related to the maxLength limit. '
      'This feature was deprecated after v1.25.0-5.0.pre.',
    )
    this.maxLengthEnforced = true,
    this.maxLengthEnforcement,
    this.onChanged,
    this.onEditingComplete,
    this.onSubmitted,
    this.onAppPrivateCommand,
    this.inputFormatters,
    this.enabled,
    this.cursorWidth = 2.0,
    this.cursorHeight,
    this.cursorRadius,
    this.cursorColor,
    this.selectionHeightStyle = ui.BoxHeightStyle.tight,
    this.selectionWidthStyle = ui.BoxWidthStyle.tight,
    this.keyboardAppearance,
    this.scrollPadding = const EdgeInsets.all(20.0),
    this.dragStartBehavior = DragStartBehavior.start,
    this.enableInteractiveSelection = true // 这个属性
  })

/// 确实也是通过这个变量控制交互toolbar的显示与否
class _TextFieldSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
  _TextFieldSelectionGestureDetectorBuilder({
    required _TextFieldState state,
  }) : _state = state,
       super(delegate: state);

  final _TextFieldState _state;

  @override
  void onForcePressStart(ForcePressDetails details) {
    super.onForcePressStart(details);
    if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
      editableText.showToolbar();
    }
  }

  @override
  void onForcePressEnd(ForcePressDetails details) {
    // Not required.
  }
// 省略源码 *****
}
复制代码

通过源码可以知道,TextField的真实渲染对象是editableText,editableText中会判断传入的enableInteractiveSelection,为false不去获取剪切板内容

/// editable_text.dart

bool get selectionEnabled => enableInteractiveSelection;

@override
  void didUpdateWidget(EditableText oldWidget) {
    super.didUpdateWidget(oldWidget);
    // 省略代码*****
    if (widget.style != oldWidget.style) {
      final TextStyle style = widget.style;
      // The _textInputConnection will pick up the new style when it attaches in
      // _openInputConnection.
      if (_hasInputConnection) {
        _textInputConnection!.setStyle(
          fontFamily: style.fontFamily,
          fontSize: style.fontSize,
          fontWeight: style.fontWeight,
          textDirection: _textDirection,
          textAlign: widget.textAlign,
        );
      }
    }
    // selectionEnabled即enableInteractiveSelection,
    // 为false不调用update()。update方法后面会讲到,其实就是这个方法在获取剪切板内容
    if (widget.selectionEnabled && pasteEnabled && widget.selectionControls?.canPaste(this) == true) {
      _clipboardStatus?.update();
    }
  }
复制代码

到这里,一切都很顺利,因为业务不需要启用交互,那么Flutter就没理由随意获取剪切板数据。然而坑就出在这里,即便enableInteractiveSelection设置为false,Flutter还是在另一个地方获取了剪切板内容,而且没有属性可配置!!!🔥 我们来到EditableTextState类,里面有_clipboardStatus私有变量,监听系统剪切板变化的变量,通过ValueNotifier进行通知。

/// editable_text.dart
void _onChangedClipboardStatus() {
    setState(() {
      // Inform the widget that the value of clipboardStatus has changed.
    });
  }

  // State lifecycle:

  @override
  void initState() {
    super.initState();
    _clipboardStatus?.addListener(_onChangedClipboardStatus);
    widget.controller.addListener(_didChangeTextEditingValue);
    _focusAttachment = widget.focusNode.attach(context);
    widget.focusNode.addListener(_handleFocusChanged);
    _scrollController = widget.scrollController ?? ScrollController();
    _scrollController!.addListener(() { _selectionOverlay?.updateForScroll(); });
    _cursorBlinkOpacityController = AnimationController(vsync: this, duration: _fadeDuration);
    _cursorBlinkOpacityController.addListener(_onCursorColorTick);
    _floatingCursorResetController = AnimationController(vsync: this);
    _floatingCursorResetController.addListener(_onFloatingCursorResetTick);
    _cursorVisibilityNotifier.value = widget.showCursor;
  }
复制代码

initState是必定要走addListener方法的,而addListener里面就自动调用了前面的_clipboardStatus.update()方法,读取了剪切板内容

/// text_selection.dart
 @override
  void addListener(VoidCallback listener) {
    if (!hasListeners) {
      WidgetsBinding.instance!.addObserver(this);
    }
    if (value == ClipboardStatus.unknown) {
      update();
    }
    super.addListener(listener);
  }

/// Check the [Clipboard] and update [value] if needed.
  Future<void> update() async {
    // iOS 14 added a notification that appears when an app accesses the
    // clipboard. To avoid the notification, don't access the clipboard on iOS,
    // and instead always show the paste button, even when the clipboard is
    // empty.
    // TODO(justinmc): Use the new iOS 14 clipboard API method hasStrings that
    // won't trigger the notification.
    // https://github.com/flutter/flutter/issues/60145
    switch (defaultTargetPlatform) {
      case TargetPlatform.iOS:
        value = ClipboardStatus.pasteable;
        return;
      case TargetPlatform.android:
      case TargetPlatform.fuchsia:
      case TargetPlatform.linux:
      case TargetPlatform.macOS:
      case TargetPlatform.windows:
        break;
    }

    ClipboardData? data;
    try {
      // 这里获取了剪切板数据
      data = await Clipboard.getData(Clipboard.kTextPlain); 
    } catch (stacktrace) {
      // In the case of an error from the Clipboard API, set the value to
      // unknown so that it will try to update again later.
      if (_disposed || value == ClipboardStatus.unknown) {
        return;
      }
      value = ClipboardStatus.unknown;
      return;
    }

    final ClipboardStatus clipboardStatus = data != null && data.text != null && data.text!.isNotEmpty
        ? ClipboardStatus.pasteable
        : ClipboardStatus.notPasteable;
    if (_disposed || clipboardStatus == value) {
      return;
    }
    value = clipboardStatus;
  }
复制代码

解析完毕,坑的原因找出来了,但是填坑却没那么简单!

如何避坑

既然源码实现如此,要改只能改源码,但我并不建议这么改,改源码对于协同开发很不友好。

  1. 当用户禁用了交互,且合规问题暴露出来,我们认为官方势必要解决这个问题,于是我先给官方提了issue
  2. 合规规定同意用户协议后,才能获取剪切板行为,那么我们完全可以从流程去避开这个问题:

用户未同意协议前,不要进入到带有输入框的页面;现在很多app也是这样做的,未同意协议就停留在闪屏页吧,能省好多事; ② 流程实在难改,就把输入框先换成普通的Container,同意后再换成textField就可以啦。

写在最后

合规问题处理起来确实是很繁琐的事情,特别是各种第三方库的坑,排查起来又非常难。但是呢,锤子🔨之所以是锤子,是因为它把所有的事情都看成钉子。 理清思路,逐一排查,认真阅读源码,同时编写一些工具去验证你的排查成果往往事半功倍。

我们一起学习、进步!!!

文章分类
Android