/* * Copyright 2023 Hongen Wang All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:proxypin/network/bin/server.dart'; import 'package:proxypin/network/channel/host_port.dart'; import 'package:proxypin/network/http/http.dart'; import 'package:proxypin/network/http/http_headers.dart'; import 'package:proxypin/network/http/http_client.dart'; import 'package:proxypin/ui/configuration.dart'; import 'package:proxypin/ui/content/body.dart'; import 'package:proxypin/utils/curl.dart'; import 'package:proxypin/utils/lang.dart'; import 'package:proxypin/ui/mobile/request/request_editor_source.dart'; import '../../component/http_method_popup.dart'; /// @author wanghongen class MobileRequestEditor extends StatefulWidget { final HttpRequest? request; final ProxyServer? proxyServer; final RequestEditorSource source; final Function(HttpRequest? request)? onExecuteRequest; final Function(HttpResponse? response)? onExecuteResponse; final HttpResponse? response; const MobileRequestEditor({ super.key, this.request, this.response, required this.proxyServer, this.source = RequestEditorSource.editor, this.onExecuteRequest, this.onExecuteResponse, }); @override State createState() { return RequestEditorState(); } } class RequestEditorState extends State with SingleTickerProviderStateMixin { final UrlQueryNotifier _queryNotifier = UrlQueryNotifier(); final requestLineKey = GlobalKey<_RequestLineState>(); final requestKey = GlobalKey<_HttpState>(); final responseKey = GlobalKey<_HttpState>(); ValueNotifier responseChange = ValueNotifier(-1); late TabController tabController; HttpRequest? request; HttpResponse? response; bool executed = false; AppLocalizations get localizations => AppLocalizations.of(context)!; var tabs = const [ Tab(text: "请求"), Tab(text: "响应"), ]; @override void dispose() { if ((widget.source == RequestEditorSource.breakpointRequest || widget.source == RequestEditorSource.breakpointResponse) && !executed) { if (widget.source == RequestEditorSource.breakpointRequest) { widget.onExecuteRequest?.call(null); } else { widget.onExecuteResponse?.call(null); } } tabController.dispose(); responseChange.dispose(); _expanded.clear(); super.dispose(); } @override void initState() { super.initState(); tabController = TabController( length: tabs.length, vsync: this, initialIndex: widget.source == RequestEditorSource.breakpointResponse ? 1 : 0); request = widget.request; response = widget.response; if (widget.request == null) { curlParse(); } } Future curlParse() async { //获取剪切板内容 var data = await Clipboard.getData('text/plain'); if (data == null || data.text == null) { return; } var text = data.text; if (text?.startsWith("http://") == true || text?.startsWith("https://") == true) { requestLineKey.currentState?.requestUrl.text = text!; return; } if (text?.trimLeft().startsWith('curl') == true && mounted) { showDialog( context: context, builder: (BuildContext context) { return AlertDialog( title: Text(localizations.prompt), content: Text(localizations.curlSchemeRequest), actions: [ TextButton(child: Text(localizations.cancel), onPressed: () => Navigator.of(context).pop()), TextButton( child: Text(localizations.confirm), onPressed: () { try { setState(() { request = Curl.parse(text!); requestKey.currentState?.change(request!); requestLineKey.currentState?.change(request?.requestUrl, request?.method); }); } catch (e) { FlutterToastr.show(localizations.fail, context); } Navigator.of(context).pop(); }), ]); }, ); } } @override Widget build(BuildContext context) { bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh'); if (!isCN) { tabs = [ Tab(text: localizations.request), Tab(text: localizations.response), ]; } var buttonText = localizations.send; IconData icon = Icons.send; if (widget.source == RequestEditorSource.breakpointRequest || widget.source == RequestEditorSource.breakpointResponse) { buttonText = localizations.execute; icon = Icons.play_arrow; } return Scaffold( appBar: AppBar( title: Text(localizations.httpRequest, style: const TextStyle(fontSize: 16)), centerTitle: true, leadingWidth: 72, leading: TextButton( onPressed: () => Navigator.pop(context), child: Text(localizations.cancel, style: Theme.of(context).textTheme.bodyMedium)), actions: [ TextButton.icon( icon: Icon(icon), label: Text(buttonText), onPressed: () { if (widget.source == RequestEditorSource.editor) { sendRequest(); } else { executeBreakpoint(); } }) ], bottom: TabBar(controller: tabController, tabs: tabs)), body: GestureDetector( onTap: () => FocusManager.instance.primaryFocus?.unfocus(), child: TabBarView( controller: tabController, children: [ _HttpWidget( title: _RequestLine(request: request, key: requestLineKey, urlQueryNotifier: _queryNotifier), message: request, key: requestKey, urlQueryNotifier: _queryNotifier, readOnly: widget.source == RequestEditorSource.breakpointResponse, ), ValueListenableBuilder( valueListenable: responseChange, builder: (_, value, __) { if (value == 0) { return const Center(child: CircularProgressIndicator()); } return _HttpWidget( key: responseKey, title: Row(children: [ Text(response?.protocolVersion ?? '', style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.blue)), const SizedBox(width: 10), Text("${localizations.statusCode}: ", style: const TextStyle(fontWeight: FontWeight.w500)), const SizedBox(width: 10), Text(response?.status.toString() ?? "", style: TextStyle( color: response?.status.isSuccessful() == true ? Colors.blue : Colors.red)) ]), readOnly: widget.source != RequestEditorSource.breakpointResponse, message: response); }), ], ))); } ///发送请求 sendRequest() async { var currentState = requestLineKey.currentState!; var headers = requestKey.currentState?.getHeaders(); var requestBody = requestKey.currentState?.getBody(); String url = currentState.requestUrl.text; HttpRequest request = HttpRequest(currentState.requestMethod, Uri.parse(url).toString(), protocolVersion: this.request?.protocolVersion ?? "HTTP/1.1"); request.headers.addAll(headers); request.body = requestBody == null ? null : utf8.encode(requestBody); var proxyInfo = widget.proxyServer?.isRunning == true ? ProxyInfo.of("127.0.0.1", widget.proxyServer?.port) : null; responseKey.currentState?.change(null); responseChange.value = 0; HttpClients.proxyRequest(proxyInfo: proxyInfo, request, timeout: Duration(seconds: 15)).then((response) { this.response = response; this.response?.request = request; responseKey.currentState?.change(response); responseChange.value = 1; // FlutterToastr.show(localizations.requestSuccess, context); }).catchError((e) { responseChange.value = -1; FlutterToastr.show('${localizations.fail}$e', context); }); tabController.animateTo(1); } void executeBreakpoint() { executed = true; if (widget.source == RequestEditorSource.breakpointRequest) { var currentState = requestLineKey.currentState!; var headers = requestKey.currentState?.getHeaders(); var requestBody = requestKey.currentState?.getBody(); String url = currentState.requestUrl.text; HttpRequest newRequest = request!.copy(uri: url); newRequest.method = currentState.requestMethod; newRequest.headers.clear(); newRequest.headers.addAll(headers); newRequest.body = requestBody == null ? null : utf8.encode(requestBody); widget.onExecuteRequest?.call(newRequest); } else if (widget.source == RequestEditorSource.breakpointResponse) { var headers = responseKey.currentState?.getHeaders(); var responseBody = responseKey.currentState?.getBody(); if (response == null) return; HttpResponse newResponse = response!.copy(); newResponse.headers.clear(); newResponse.headers.addAll(headers); newResponse.body = responseBody == null ? null : utf8.encode(responseBody); widget.onExecuteResponse?.call(newResponse); } } } typedef ParamCallback = void Function(String param); class UrlQueryNotifier { ParamCallback? _urlNotifier; ParamCallback? _paramNotifier; ParamCallback urlListener(ParamCallback listener) => _urlNotifier = listener; ParamCallback paramListener(ParamCallback listener) => _paramNotifier = listener; void onUrlChange(String url) => _urlNotifier?.call(url); void onParamChange(String param) => _paramNotifier?.call(param); } class _HttpWidget extends StatefulWidget { final HttpMessage? message; final bool readOnly; final Widget title; final UrlQueryNotifier? urlQueryNotifier; const _HttpWidget({this.message, this.readOnly = false, super.key, required this.title, this.urlQueryNotifier}); @override State createState() { return _HttpState(); } } class _HttpState extends State<_HttpWidget> with AutomaticKeepAliveClientMixin { final headerKey = GlobalKey(); Map> initHeader = {}; HttpMessage? message; String? body; AppLocalizations get localizations => AppLocalizations.of(context)!; @override bool get wantKeepAlive => true; String? getBody() { return body; } @override void initState() { super.initState(); message = widget.message; body = widget.message?.bodyAsString; if (widget.message?.headers == null && !widget.readOnly) { initHeader["User-Agent"] = ["ProxyPin/${AppConfiguration.version}"]; initHeader["Accept"] = ["*/*"]; return; } } void change(HttpMessage? message) { this.message = message; body = message?.bodyAsString; headerKey.currentState?.refreshParam(message?.headers.getHeaders()); setState(() {}); } HttpHeaders? getHeaders() { return HttpHeaders.fromJson(headerKey.currentState?.getParams() ?? {}); } @override Widget build(BuildContext context) { super.build(context); if (message == null && widget.readOnly) { return Center(child: Text(localizations.emptyData)); } return SingleChildScrollView( padding: const EdgeInsets.all(15), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ widget.title, if (widget.urlQueryNotifier != null) KeyValWidget( title: 'URL${localizations.param}', paramNotifier: widget.urlQueryNotifier, params: message is HttpRequest ? (message as HttpRequest).requestUri?.queryParametersAll : null, expanded: false, ), KeyValWidget( title: "Headers", params: message?.headers.getHeaders() ?? initHeader, key: headerKey, suggestions: HttpHeaders.commonHeaderKeys, readOnly: widget.readOnly), // 请求头 const SizedBox(height: 10), const Text("Body", style: TextStyle(fontWeight: FontWeight.w500, color: Colors.blue)), _body(), const SizedBox(height: 10), ])); } Widget _body() { if (widget.readOnly) { return SingleChildScrollView(child: HttpBodyWidget(httpMessage: message)); } return TextField( controller: TextEditingController(text: body), readOnly: widget.readOnly, onChanged: (value) => body = value, minLines: 3, maxLines: 15); } } class _RequestLine extends StatefulWidget { final HttpRequest? request; final UrlQueryNotifier? urlQueryNotifier; const _RequestLine({this.request, super.key, this.urlQueryNotifier}); @override State createState() { return _RequestLineState(); } } class _RequestLineState extends State<_RequestLine> { TextEditingController requestUrl = TextEditingController(text: ""); HttpMethod requestMethod = HttpMethod.get; @override void initState() { super.initState(); widget.urlQueryNotifier?.paramListener((param) => onQueryChange(param)); if (widget.request == null) { requestUrl.text = 'https://'; return; } var request = widget.request!; requestUrl.text = request.requestUrl; requestMethod = request.method; } @override dispose() { requestUrl.dispose(); super.dispose(); } void change(String? requestUrl, HttpMethod? requestMethod) { this.requestUrl.text = requestUrl ?? this.requestUrl.text; this.requestMethod = requestMethod ?? this.requestMethod; urlNotifier(); } void urlNotifier() { var splitFirst = requestUrl.text.splitFirst("?".codeUnits.first); widget.urlQueryNotifier?.onUrlChange(splitFirst.length > 1 ? splitFirst.last : ''); } void onQueryChange(String query) { var url = requestUrl.text; var indexOf = url.indexOf("?"); if (indexOf == -1) { requestUrl.text = "$url?$query"; } else { requestUrl.text = "${url.substring(0, indexOf)}?$query"; } setState(() {}); } @override Widget build(BuildContext context) { TextInput; return TextField( style: const TextStyle(fontSize: 14), minLines: 1, maxLines: 5, autofocus: false, controller: requestUrl, decoration: InputDecoration( prefixIcon: Padding( padding: const EdgeInsets.only(left: 6, right: 6), child: MethodPopupMenu( value: requestMethod, showSeparator: true, onChanged: (val) { setState(() => requestMethod = val!); }, ), ), isDense: true, border: const OutlineInputBorder(borderSide: BorderSide()), enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.grey, width: 0.3))), onChanged: (value) { urlNotifier(); }); } } class KeyVal { bool enabled = true; String key; String value; KeyVal(this.key, this.value); } ///key value class KeyValWidget extends StatefulWidget { final String title; final Map>? params; final bool readOnly; //只读 final UrlQueryNotifier? paramNotifier; final bool expanded; final List? suggestions; const KeyValWidget( {super.key, this.params, this.readOnly = false, this.paramNotifier, required this.title, this.expanded = true, this.suggestions}); @override State createState() { return KeyValState(); } } final Map _expanded = {}; class KeyValState extends State { final List _params = []; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); widget.params?.forEach((name, values) { for (var val in values) { var keyVal = KeyVal(name, val); _params.add(keyVal); } }); widget.paramNotifier?.urlListener((url) => onChange(url)); } //监听url发生变化 更改表单 void onChange(String value) { var query = value.split("&"); int index = 0; while (index < query.length) { var splitFirst = query[index].splitFirst('='.codeUnits.first); String key = splitFirst.first; String? val = splitFirst.length == 1 ? null : splitFirst.last; if (_params.length <= index) { _params.add(KeyVal(key, val ?? '')); continue; } var keyVal = _params[index++]; keyVal.key = key; keyVal.value = val ?? ''; } _params.length = index; setState(() {}); } void notifierChange() { if (widget.paramNotifier == null) return; String query = _params .where((e) => e.enabled && e.key.isNotEmpty) .map((e) => "${e.key}=${e.value}".replaceAll("&", "%26")) .join("&"); widget.paramNotifier?.onParamChange(query); } ///获取所有请求头 Map> getParams() { Map> map = {}; for (var keVal in _params) { if (keVal.key.isEmpty || !keVal.enabled) { continue; } map[keVal.key] ??= []; map[keVal.key]!.add(keVal.value); } return map; } //刷新param void refreshParam(Map>? headers) { _params.clear(); setState(() { headers?.forEach((name, values) { for (var val in values) { _params.add(KeyVal(name, val)); } }); }); } @override Widget build(BuildContext context) { return ExpansionTile( title: Text(widget.title, style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.blue)), tilePadding: const EdgeInsets.only(left: 0, top: 10, bottom: 10), initiallyExpanded: _expanded[widget.title] ?? widget.expanded, onExpansionChanged: (value) => _expanded[widget.title] = value, shape: const Border(), children: [ ..._buildRows(), widget.readOnly ? const SizedBox() : Container( alignment: Alignment.center, child: TextButton( onPressed: () { var keyVal = KeyVal("", ""); _params.add(keyVal); modifyParam(keyVal); }, child: Text(localizations.add, textAlign: TextAlign.center))) //添加按钮 ], ); } List _buildRows() { List list = []; for (var element in _params) { Widget headerWidget = Padding(padding: const EdgeInsets.only(top: 5, bottom: 5), child: row(element)); if (!widget.readOnly) { headerWidget = InkWell(onTap: () => modifyParam(element), onLongPress: () => deleteHeader(element), child: headerWidget); } list.add(headerWidget); list.add(const Divider(thickness: 0.2)); } return list; } //隐藏输入框焦点 void hideKeyword(BuildContext context) { FocusScopeNode currentFocus = FocusScope.of(context); if (!currentFocus.hasPrimaryFocus && currentFocus.focusedChild != null) { currentFocus.focusedChild?.unfocus(); } } /// 修改请求头 void modifyParam(KeyVal keyVal) { //隐藏输入框焦点 hideKeyword(context); String headerName = keyVal.key; String val = keyVal.value; showDialog( context: context, builder: (ctx) { return StatefulBuilder(builder: (context, setState) { return AlertDialog( titlePadding: const EdgeInsets.only(left: 25, top: 10), actionsPadding: const EdgeInsets.only(right: 10, bottom: 10), title: Text(localizations.modifyRequestHeader, style: const TextStyle(fontSize: 18)), content: Wrap( children: [ if (widget.suggestions != null) Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } return widget.suggestions!.where((String option) { return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); }); }, onSelected: (String selection) { setState(() { headerName = selection; }); }, fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { return TextFormField( controller: textEditingController, focusNode: focusNode, minLines: 1, maxLines: 3, decoration: InputDecoration(labelText: localizations.headerName), onChanged: (value) { headerName = value; setState(() {}); }, ); }, initialValue: TextEditingValue(text: headerName), optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { return Align( alignment: Alignment.topLeft, child: Material( elevation: 4.0, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300), child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, itemCount: options.length, itemBuilder: (BuildContext context, int index) { final String option = options.elementAt(index); return InkWell( onTap: () { onSelected(option); }, child: Container( padding: const EdgeInsets.all(10.0), child: _buildHighlightText(option, headerName), ), ); }, ), ), ), ); }, ) else TextFormField( minLines: 1, maxLines: 3, initialValue: headerName, decoration: InputDecoration(labelText: localizations.headerName), onChanged: (value) { headerName = value; setState(() {}); }, ), if (HttpHeaders.commonHeaderValues.containsKey(headerName)) Autocomplete( optionsBuilder: (TextEditingValue textEditingValue) { if (textEditingValue.text.isEmpty) { return const Iterable.empty(); } return HttpHeaders.commonHeaderValues[headerName]!.where((String option) { return option.toLowerCase().contains(textEditingValue.text.toLowerCase()); }); }, onSelected: (String selection) { val = selection; }, fieldViewBuilder: (BuildContext context, TextEditingController textEditingController, FocusNode focusNode, VoidCallback onFieldSubmitted) { return TextFormField( controller: textEditingController, focusNode: focusNode, minLines: 1, maxLines: 8, decoration: InputDecoration(labelText: localizations.value), onChanged: (value) => val = value, ); }, initialValue: TextEditingValue(text: val), optionsViewBuilder: (BuildContext context, AutocompleteOnSelected onSelected, Iterable options) { return Align( alignment: Alignment.topLeft, child: Material( elevation: 4.0, child: ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200, maxWidth: 300), child: ListView.builder( padding: EdgeInsets.zero, shrinkWrap: true, itemCount: options.length, itemBuilder: (BuildContext context, int index) { final String option = options.elementAt(index); return InkWell( onTap: () { onSelected(option); }, child: Container( padding: const EdgeInsets.all(10.0), child: _buildHighlightText(option, val), ), ); }, ), ), ), ); }, ) else TextFormField( minLines: 1, maxLines: 8, initialValue: val, decoration: InputDecoration(labelText: localizations.value), onChanged: (value) => val = value, ) ], ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), TextButton( onPressed: () { this.setState(() { keyVal.key = headerName; keyVal.value = val; }); notifierChange(); Navigator.pop(ctx); }, child: Text(localizations.modify)), ], ); }); }); } //删除 deleteHeader(KeyVal keyVal) { showDialog( context: context, builder: (ctx) { return AlertDialog( title: Text(localizations.deleteHeaderConfirm, style: const TextStyle(fontSize: 18)), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: Text(localizations.cancel)), TextButton( onPressed: () { setState(() => _params.remove(keyVal)); notifierChange(); Navigator.pop(ctx); }, child: Text(localizations.delete)), ], ); }); } Widget row(KeyVal keyVal) { return Row(children: [ if (!widget.readOnly) Checkbox( value: keyVal.enabled, onChanged: (val) { setState(() { keyVal.enabled = val!; }); notifierChange(); }), Expanded(flex: 4, child: Text(keyVal.key, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500))), const Text(":", style: TextStyle(color: Colors.orange, fontWeight: FontWeight.w600)), const SizedBox(width: 8), Expanded( flex: 6, child: Text(keyVal.value, style: const TextStyle(fontSize: 13), maxLines: 5, overflow: TextOverflow.ellipsis), ), ]); } Widget _buildHighlightText(String text, String query) { if (query.isEmpty) { return Text(text); } int index = text.toLowerCase().indexOf(query.toLowerCase()); if (index < 0) { return Text(text); } return Text.rich(TextSpan(children: [ TextSpan(text: text.substring(0, index)), TextSpan( text: text.substring(index, index + query.length), style: TextStyle(color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold)), TextSpan(text: text.substring(index + query.length)) ])); } }