Flutter輸入框獲取剪下板-合規問題踩坑

語言: CN / TW / HK

前言:公司法務部檢測出Flutter開發的App存在未同意隱私協議先獲取系統剪下板資料的問題,要求整改。經過一系列除錯後,定位到原來是Flutter輸入框的坑,只要使用到輸入框,就會先獲取下剪下板資料。還沒有屬性可以關閉,著實踩坑,以下記錄分享給大家,希望能穩穩避坑......

合規問題-獲取剪下板資料

這個問題首次出現其實是在去年iOS14上線直接把app應用獲取剪貼簿內容的行為直接暴露出來。2020年6月29日,抖音海外版TikTok因為頻繁讀取使用者剪貼簿內容引爭議,甚至被作為後面將其驅逐出海外市場的導火索。 國內監管部門雖然並沒有明確的對訪問剪貼簿內容的直接要求,但是隨著近年來社會上對隱私保護的重視和媒體關注,接下來會有發酵可能。

獲取剪下板內容的應用場景

目前國內剪下板內容主要應用場景是類似淘口令之類的方式,通過讀取剪下板的內容,彈出對應的內容;更有甚者,採集使用者剪下板資料進行大資料分析,因為使用者複製的內容,具備極高的使用者興趣導向,作為大資料訓練素材準確性很高。 而Flutter輸入框為何也獲取剪下板內容,有留意過長按輸入框的互動嗎? 長按會有toolbar提供貼上、複製等功能,而貼上就必須先獲取剪下板的內容。 然後基本上App的登入頁都有輸入框,只要你在使用者同意隱私協議之前,顯示了Flutter中的TextField,就必然會觸發這個潛在的合規問題。 🐶

Flutter輸入框是如何獲取剪下板資料的

這個問題需要我們一步步來跟蹤原始碼。 1. 首先看TextField的原始碼,有一個屬性enableInteractiveSelection,可以理解為啟用互動式選擇。從業務邏輯出發,把這個屬性設為false,應該就不會出現toolbar了,那應該不需要獲取剪下板資料以提供貼上功能。 ``` dart /// 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不去獲取剪下板內容 dart /// 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<ClipboardStatus>進行通知。** dart /// 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()方法,讀取了剪下板內容

``` dart /// 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 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就可以啦。

寫在最後

合規問題處理起來確實是很繁瑣的事情,特別是各種第三方庫的坑,排查起來又非常難。但是呢,錘子🔨之所以是錘子,是因為它把所有的事情都看成釘子。 理清思路,逐一排查,認真閱讀原始碼,同時編寫一些工具去驗證你的排查成果往往事半功倍。

我們一起學習、進步!!!