Flutter輸入框獲取剪下板-合規問題踩坑
前言:公司法務部檢測出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
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就可以啦。
寫在最後
合規問題處理起來確實是很繁瑣的事情,特別是各種第三方庫的坑,排查起來又非常難。但是呢,錘子🔨之所以是錘子,是因為它把所有的事情都看成釘子。 理清思路,逐一排查,認真閱讀原始碼,同時編寫一些工具去驗證你的排查成果往往事半功倍。
我們一起學習、進步!!!