From 67d16c761de9f64804f16fcb3acd2a76957174b9 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Mon, 11 Mar 2024 01:55:17 +0800 Subject: [PATCH] script Batch operations --- android/app/build.gradle | 2 +- .../kotlin/com/network/proxy/MainActivity.kt | 2 + .../network/proxy/plugin/ProcessInfoPlugin.kt | 93 +++++ .../network/proxy/vpn/ConnectionHandler.kt | 8 +- .../network/proxy/vpn/ConnectionManager.kt | 13 +- .../com/network/proxy/vpn/ProxyVpnThread.kt | 3 +- lib/native/process_info.dart | 11 + lib/network/components/script_manager.dart | 2 +- lib/ui/desktop/toolbar/setting/script.dart | 325 +++++++++++------- lib/ui/mobile/setting/script.dart | 276 ++++++++++----- 10 files changed, 515 insertions(+), 220 deletions(-) create mode 100644 android/app/src/main/kotlin/com/network/proxy/plugin/ProcessInfoPlugin.kt create mode 100644 lib/native/process_info.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index ffba0cc..ce1a196 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -48,7 +48,7 @@ android { defaultConfig { applicationId "com.network.proxy" - ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', "x86", 'x86_64' } + ndk { abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64' } // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion 21 targetSdkVersion flutter.targetSdkVersion diff --git a/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt b/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt index a0b3354..4a1cd81 100644 --- a/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.res.Configuration import com.network.proxy.plugin.AppLifecyclePlugin import com.network.proxy.plugin.InstalledAppsPlugin import com.network.proxy.plugin.PictureInPicturePlugin +import com.network.proxy.plugin.ProcessInfoPlugin import com.network.proxy.plugin.VpnServicePlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -39,6 +40,7 @@ class MainActivity : FlutterActivity() { flutterEngine.plugins.add(PictureInPicturePlugin()) flutterEngine.plugins.add(lifecycleChannel) flutterEngine.plugins.add(InstalledAppsPlugin()) + flutterEngine.plugins.add(ProcessInfoPlugin.instance) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/ProcessInfoPlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/ProcessInfoPlugin.kt new file mode 100644 index 0000000..ee5241c --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/ProcessInfoPlugin.kt @@ -0,0 +1,93 @@ +package com.network.proxy.plugin + +import android.content.Context +import android.net.ConnectivityManager +import android.os.Build +import android.os.Process.INVALID_UID +import android.system.OsConstants.IPPROTO_TCP +import android.util.Log +import com.network.proxy.ProxyVpnService +import com.network.proxy.vpn.ConnectionManager +import com.network.proxy.vpn.TAG +import com.network.proxy.vpn.util.PacketUtil +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodChannel +import java.net.InetSocketAddress +import java.util.concurrent.ConcurrentHashMap +import kotlin.math.log + +/** + * 进程信息管理器 + * + * @author wanghongen + */ +class ProcessInfoPlugin private constructor() : AndroidFlutterPlugin() { + companion object { + const val CHANNEL = "com.proxy/processInfo" + + val instance = ProcessInfoPlugin() + } + + private val cache = ConcurrentHashMap() + lateinit var context: Context + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val channel = MethodChannel(binding.binaryMessenger, CHANNEL) + channel.setMethodCallHandler { call, result -> + when (call.method) { + "getProcessByPort" -> { + val host = call.argument("host") + val port = call.argument("port") + val localAddress = InetSocketAddress(host!!, port!!) + + result.success(null) + } + + else -> result.notImplemented() + } + } + } + + fun getProcessInfo( + localAddress: InetSocketAddress, remoteAddress: InetSocketAddress + ): AppInfo? { + val connectivityManager: ConnectivityManager = + activity.applicationContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + Log.i(TAG, "getProcessInfo: $localAddress $remoteAddress") + + val uid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectivityManager.getConnectionOwnerUid(IPPROTO_TCP, localAddress, remoteAddress) + } else { + val method = ConnectivityManager::class.java.getMethod( + "getConnectionOwnerUid", + Int::class.javaPrimitiveType, + InetSocketAddress::class.java, + InetSocketAddress::class.java + ) + method.invoke( + connectivityManager, IPPROTO_TCP, localAddress, remoteAddress + ) as Int + } + + if (uid != INVALID_UID) { + return getProcessInfo(uid) + } + return null + } + + private fun getProcessInfo(uid: Int): AppInfo? { + val packageManager = activity.packageManager + + var appInfo = cache[uid] + if (appInfo != null) return appInfo + + val pkgNames = packageManager.getPackagesForUid(uid) ?: return null + for (pkgName in pkgNames) { + val applicationInfo = packageManager.getApplicationInfo(pkgName, 0) + appInfo = AppInfo.create(packageManager, applicationInfo) + cache[uid] = appInfo + return appInfo + } + return null + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionHandler.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionHandler.kt index 9168662..0a6e111 100644 --- a/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionHandler.kt +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionHandler.kt @@ -135,12 +135,10 @@ class ConnectionHandler( * 获取代理地址 */ private fun getProxyAddress( - packetData: ByteBuffer, - destinationIP: Int, - destinationPort: Int - ): SocketAddress { + packetData: ByteBuffer, destinationIP: Int, destinationPort: Int + ): InetSocketAddress { val supperProtocol = supperProtocol(packetData) - var socketAddress: SocketAddress? = null + var socketAddress: InetSocketAddress? = null if (supperProtocol) { socketAddress = manager.proxyAddress } diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionManager.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionManager.kt index 9b7f49f..ded306d 100644 --- a/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionManager.kt +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/ConnectionManager.kt @@ -18,7 +18,12 @@ import java.util.concurrent.ConcurrentMap /** * 管理VPN客户端的连接 */ -class ConnectionManager : CloseableConnection { +class ConnectionManager private constructor() : CloseableConnection { + //单例 + companion object { + val instance = ConnectionManager() + } + private val table: ConcurrentMap = ConcurrentHashMap() var proxyAddress: InetSocketAddress? = null @@ -42,9 +47,9 @@ class ConnectionManager : CloseableConnection { */ fun closeConnection(protocol: Protocol, ip: Int, port: Int, srcIp: Int, srcPort: Int) { val key = Connection.getConnectionKey(protocol, ip, port, srcIp, srcPort) - val session: Connection? = table.remove(key) - session?.let { - val channel = session.channel + val connection: Connection? = table.remove(key) + connection?.let { + val channel = connection.channel try { channel?.close() } catch (e: IOException) { diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/ProxyVpnThread.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/ProxyVpnThread.kt index d16ae52..94c2fe1 100644 --- a/android/app/src/main/kotlin/com/network/proxy/vpn/ProxyVpnThread.kt +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/ProxyVpnThread.kt @@ -14,6 +14,7 @@ import java.nio.ByteBuffer /** * VPN线程,负责处理VPN接收到的数据包 + * @author wanghongen */ class ProxyVpnThread( vpnInterface: ParcelFileDescriptor, @@ -38,7 +39,7 @@ class ProxyVpnThread( private val nioService = SocketNIODataService(vpnPacketWriter) private val dataServiceThread = Thread(nioService, "Socket NIO thread") - private val manager = ConnectionManager().apply { + private val manager = ConnectionManager.instance.apply { //流量转发到代理地址 this.proxyAddress = InetSocketAddress(proxyHost, proxyPort) } diff --git a/lib/native/process_info.dart b/lib/native/process_info.dart new file mode 100644 index 0000000..ce9534c --- /dev/null +++ b/lib/native/process_info.dart @@ -0,0 +1,11 @@ +import 'package:flutter/services.dart'; +import 'package:network_proxy/native/installed_apps.dart'; + +class ProcessInfoPlugin { + static const MethodChannel _methodChannel = MethodChannel('com.proxy/processInfo'); + + static Future getProcessByPort(String host, int port) { + return _methodChannel.invokeMethod('getProcessByPort', {"host": host, "port": port}).then( + (value) => value == null ? null : AppInfo.formJson(value)); + } +} diff --git a/lib/network/components/script_manager.dart b/lib/network/components/script_manager.dart index 5a4807a..796d5fb 100644 --- a/lib/network/components/script_manager.dart +++ b/lib/network/components/script_manager.dart @@ -304,7 +304,7 @@ class ScriptItem { return urlReg!.hasMatch(url); } - factory ScriptItem.fromJson(Map json) { + factory ScriptItem.fromJson(Map json) { return ScriptItem(json['enabled'], json['name'], json['url'], scriptPath: json['scriptPath']); } diff --git a/lib/ui/desktop/toolbar/setting/script.dart b/lib/ui/desktop/toolbar/setting/script.dart index a93be6b..0e08644 100644 --- a/lib/ui/desktop/toolbar/setting/script.dart +++ b/lib/ui/desktop/toolbar/setting/script.dart @@ -86,7 +86,9 @@ class _ScriptWidgetState extends State { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).dialogBackgroundColor, + backgroundColor: Theme + .of(context) + .dialogBackgroundColor, appBar: AppBar( title: Text(localizations.script, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), toolbarHeight: 36, @@ -96,7 +98,8 @@ class _ScriptWidgetState extends State { child: futureWidget( ScriptManager.instance, loading: true, - (data) => Column( + (data) => + Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -104,56 +107,34 @@ class _ScriptWidgetState extends State { SizedBox( width: 300, child: SwitchWidget( - title: localizations.enableScript, - subtitle: localizations.scriptUseDescribe, - value: data.enabled, - onChanged: (value) { - data.enabled = value; - _refreshScript(); - }, - )), + title: localizations.enableScript, + subtitle: localizations.scriptUseDescribe, + value: data.enabled, + onChanged: (value) { + data.enabled = value; + _refreshScript(); + })), Expanded( child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const SizedBox(width: 10), - FilledButton( - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), - onPressed: scriptEdit, - child: Text(localizations.add), - ), - const SizedBox(width: 10), - OutlinedButton( - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), - onPressed: import, - child: Text(localizations.import), - ) - ], - )), + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const SizedBox(width: 10), + FilledButton.icon( + icon: const Icon(Icons.add, size: 18), + onPressed: scriptEdit, + label: Text(localizations.add)), + const SizedBox(width: 10), + FilledButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), + onPressed: import, + label: Text(localizations.import), + ), + ], + )), const SizedBox(width: 15) ]), const SizedBox(height: 5), - Container( - padding: const EdgeInsets.only(top: 10), - constraints: const BoxConstraints(maxHeight: 500, minHeight: 300), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), - child: SingleChildScrollView( - child: Column(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - width: 200, - padding: const EdgeInsets.only(left: 10), - child: Text(localizations.name)), - SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), - const VerticalDivider(), - const Expanded(child: Text("URL")), - ], - ), - const Divider(thickness: 0.5), - ScriptList(scripts: data.list, windowId: widget.windowId), - ]))), + ScriptList(scripts: data.list, windowId: widget.windowId), ])))); } @@ -167,8 +148,17 @@ class _ScriptWidgetState extends State { try { var json = jsonDecode(await File(file).readAsString()); - var scriptItem = ScriptItem.fromJson(json); - (await ScriptManager.instance).addScript(scriptItem, json['script']); + var scriptManager = (await ScriptManager.instance); + if (json is List) { + for (var item in json) { + var scriptItem = ScriptItem.fromJson(item); + await scriptManager.addScript(scriptItem, item['script']); + } + } else { + var scriptItem = ScriptItem.fromJson(json); + await scriptManager.addScript(scriptItem, json['script']); + } + _refreshScript(); if (mounted) { FlutterToastr.show(localizations.importSuccess, context); @@ -242,12 +232,13 @@ class _ScriptEditState extends State { text: localizations.useGuide, style: const TextStyle(color: Colors.blue, fontSize: 14), recognizer: TapGestureRecognizer() - ..onTap = () => DesktopMultiWindow.invokeMethod( - 0, - "launchUrl", - isCN - ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' - : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script'))), + ..onTap = () => + DesktopMultiWindow.invokeMethod( + 0, + "launchUrl", + isCN + ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' + : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script'))), const Expanded(child: Align(alignment: Alignment.topRight, child: CloseButton())) ]), actionsPadding: const EdgeInsets.only(right: 10, bottom: 10), @@ -305,22 +296,25 @@ class _ScriptEditState extends State { SizedBox(width: 50, child: Text(label)), Expanded( child: TextFormField( - controller: controller, - validator: (val) => val?.isNotEmpty == true ? null : "", - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - contentPadding: const EdgeInsets.all(10), - errorStyle: const TextStyle(height: 0, fontSize: 0), - focusedBorder: focusedBorder(), - isDense: true, - border: const OutlineInputBorder()), - )) + controller: controller, + validator: (val) => val?.isNotEmpty == true ? null : "", + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + contentPadding: const EdgeInsets.all(10), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )) ]); } InputBorder focusedBorder() { - return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); + return OutlineInputBorder(borderSide: BorderSide(color: Theme + .of(context) + .colorScheme + .primary, width: 2)); } } @@ -336,48 +330,91 @@ class ScriptList extends StatefulWidget { } class _ScriptListState extends State { - int selected = -1; + Set selected = {}; + bool isPress = false; AppLocalizations get localizations => AppLocalizations.of(context)!; @override Widget build(BuildContext context) { - return Column(children: rows(widget.scripts)); + return GestureDetector( + onSecondaryTapDown: (details) => showGlobalMenu(details.globalPosition), + onTapDown: (details) { + if (selected.isEmpty) { + return; + } + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + return; + } + setState(() { + selected.clear(); + }); + }, + child: Listener( + onPointerUp: (details) => isPress = false, + onPointerDown: (details) => isPress = true, + child: Container( + padding: const EdgeInsets.only(top: 10), + height: 530, + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: SingleChildScrollView( + child: Column(children: [ + Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Container( + width: 200, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text("URL")), + ]), + const Divider(thickness: 0.5), + Column(children: rows(widget.scripts)) + ]))))); } List rows(List list) { - var primaryColor = Theme.of(context).colorScheme.primary; + var primaryColor = Theme + .of(context) + .colorScheme + .primary; return List.generate(list.length, (index) { return InkWell( - // onTap: () { - // selected[index] = !(selected[index] ?? false); - // setState(() {}); - // }, + // onTap: () { + // selected[index] = !(selected[index] ?? false); + // setState(() {}); + // }, highlightColor: Colors.transparent, splashColor: Colors.transparent, hoverColor: primaryColor.withOpacity(0.3), - onDoubleTap: () async { - String script = await (await ScriptManager.instance).getScript(list[index]); - if (!mounted) { + onDoubleTap: () => showEdit(index), + onSecondaryTapDown: (details) => showMenus(details, index), + onHover: (hover) { + if (isPress && !selected.contains(index)) { + setState(() { + selected.add(index); + }); + } + }, + onTap: () { + if (HardwareKeyboard.instance.isMetaPressed || HardwareKeyboard.instance.isControlPressed) { + setState(() { + selected.contains(index) ? selected.remove(index) : selected.add(index); + }); return; } - showDialog( - barrierDismissible: false, - context: context, - builder: (_) => ScriptEdit(scriptItem: list[index], script: script)).then((value) { - if (value != null) { - setState(() {}); - } + if (selected.isEmpty) { + return; + } + setState(() { + selected.clear(); }); }, - onSecondaryTapDown: (details) => showMenus(details, index), child: Container( - color: selected == index + color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.1) - : null, + ? Colors.grey.withOpacity(0.1) + : null, height: 30, padding: const EdgeInsets.all(5), child: Row( @@ -400,66 +437,120 @@ class _ScriptListState extends State { }); } + showGlobalMenu(Offset offset) { + showContextMenu(context, offset, items: [ + PopupMenuItem(height: 35, child: Text(localizations.newBuilt), onTap: () => showEdit()), + PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(selected.toList())), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.enableSelect), onTap: () => enableStatus(true)), + PopupMenuItem(height: 35, child: Text(localizations.disableSelect), onTap: () => enableStatus(false)), + const PopupMenuDivider(), + PopupMenuItem(height: 35, child: Text(localizations.deleteSelect), onTap: () => removeScripts(selected.toList())), + ]); + } + //点击菜单 showMenus(TapDownDetails details, int index) { + if (selected.length > 1) { + showGlobalMenu(details.globalPosition); + return; + } setState(() { - selected = index; + selected.add(index); }); + showContextMenu(context, details.globalPosition, items: [ - PopupMenuItem( - height: 35, - child: Text(localizations.edit), - onTap: () async { - String script = await (await ScriptManager.instance).getScript(widget.scripts[index]); - if (!mounted) { - return; - } - showDialog( - barrierDismissible: false, - context: context, - builder: (_) => ScriptEdit(scriptItem: widget.scripts[index], script: script)).then((value) { - if (value != null) { - setState(() {}); - } - }); - }), - PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export(widget.scripts[index])), + PopupMenuItem(height: 35, child: Text(localizations.edit), onTap: () => showEdit(index)), + PopupMenuItem(height: 35, child: Text(localizations.export), onTap: () => export([index])), PopupMenuItem( height: 35, child: widget.scripts[index].enabled ? Text(localizations.disabled) : Text(localizations.enable), onTap: () { widget.scripts[index].enabled = !widget.scripts[index].enabled; + _refreshScript(); }), const PopupMenuDivider(), PopupMenuItem( height: 35, child: Text(localizations.delete), onTap: () async { - (await ScriptManager.instance).removeScript(index); + var scriptManager = await ScriptManager.instance; + await scriptManager.removeScript(index); _refreshScript(); - if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); }), ]).then((value) { - setState(() { - selected = -1; - }); + if (mounted) { + setState(() { + selected.remove(index); + }); + } + }); + } + + showEdit([int? index]) async { + String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + if (!mounted) { + return; + } + + showDialog( + barrierDismissible: false, + context: context, + builder: (_) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script)) + .then((value) { + if (value != null) { + setState(() {}); + } }); } //导出js - export(ScriptItem item) async { + export(List indexes) async { + if (indexes.isEmpty) return; //文件名称 - String fileName = '${item.name}.json'; + String fileName = 'proxypin-scripts.json'; String? saveLocation = await DesktopMultiWindow.invokeMethod(0, 'getSaveLocation', fileName); WindowController.fromWindowId(widget.windowId).show(); if (saveLocation == null) { return; } - var json = item.toJson(); - json.remove("scriptPath"); - json['script'] = await (await ScriptManager.instance).getScript(item); + var scriptManager = await ScriptManager.instance; + List json = []; + for (var idx in indexes) { + var item = widget.scripts[idx]; + var map = item.toJson(); + map.remove("scriptPath"); + map['script'] = await scriptManager.getScript(item); + json.add(map); + } + final XFile xFile = XFile.fromData(utf8.encode(jsonEncode(json)), mimeType: 'json'); await xFile.saveTo(saveLocation); if (mounted) FlutterToastr.show(localizations.exportSuccess, context); } + + enableStatus(bool enable) { + for (var idx in selected) { + widget.scripts[idx].enabled = enable; + } + setState(() {}); + _refreshScript(); + } + + removeScripts(List indexes) async { + if (indexes.isEmpty) return; + showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { + var scriptManager = await ScriptManager.instance; + for (var idx in indexes) { + await scriptManager.removeScript(idx); + } + + setState(() { + selected.clear(); + }); + _refreshScript(); + + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); + } } diff --git a/lib/ui/mobile/setting/script.dart b/lib/ui/mobile/setting/script.dart index 71c70ce..2bfe4c6 100644 --- a/lib/ui/mobile/setting/script.dart +++ b/lib/ui/mobile/setting/script.dart @@ -51,7 +51,8 @@ class _MobileScriptState extends State { child: futureWidget( ScriptManager.instance, loading: true, - (data) => Column( + (data) => + Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: [ @@ -72,42 +73,21 @@ class _MobileScriptState extends State { mainAxisAlignment: MainAxisAlignment.end, children: [ const SizedBox(width: 10), - FilledButton( - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), - onPressed: scriptEdit, - child: Text(localizations.add), + FilledButton.icon( + icon: const Icon(Icons.add, size: 18), + onPressed: scriptEdit, + label: Text(localizations.add)), + const SizedBox(width: 10), + FilledButton.icon( + icon: const Icon(Icons.input_rounded, size: 18), + onPressed: import, + label: Text(localizations.import), ), const SizedBox(width: 10), - OutlinedButton( - style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(left: 20, right: 20)), - onPressed: import, - child: Text(localizations.import), - ), - const SizedBox(width: 15), ], ), const SizedBox(height: 5), - Container( - padding: const EdgeInsets.only(top: 10), - constraints: const BoxConstraints(maxHeight: 500, minHeight: 300), - decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), - child: SingleChildScrollView( - child: Column(children: [ - Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Container( - width: 100, - padding: const EdgeInsets.only(left: 10), - child: Text(localizations.name)), - SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), - const VerticalDivider(), - const Expanded(child: Text("URL")), - ], - ), - const Divider(thickness: 0.5), - ScriptList(scripts: data.list), - ]))), + Expanded(child: ScriptList(scripts: data.list)), ])))); } @@ -119,9 +99,19 @@ class _MobileScriptState extends State { } try { + var scriptManager = (await ScriptManager.instance); var json = jsonDecode(utf8.decode(await file.readAsBytes())); - var scriptItem = ScriptItem.fromJson(json); - (await ScriptManager.instance).addScript(scriptItem, json['script']); + + if (json is List) { + for (var item in json) { + var scriptItem = ScriptItem.fromJson(item); + await scriptManager.addScript(scriptItem, item['script']); + } + } else { + var scriptItem = ScriptItem.fromJson(json); + await scriptManager.addScript(scriptItem, json['script']); + } + _refreshScript(); if (mounted) { FlutterToastr.show(localizations.importSuccess, context); @@ -193,9 +183,10 @@ class _ScriptEditState extends State { text: localizations.useGuide, style: const TextStyle(color: Colors.blue, fontSize: 14), recognizer: TapGestureRecognizer() - ..onTap = () => launchUrl(Uri.parse(isCN - ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' - : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script')))), + ..onTap = () => + launchUrl(Uri.parse(isCN + ? 'https://gitee.com/wanghongenpin/network-proxy-flutter/wikis/%E8%84%9A%E6%9C%AC' + : 'https://github.com/wanghongenpin/network_proxy_flutter/wiki/Script')))), ]), actions: [ TextButton( @@ -249,22 +240,25 @@ class _ScriptEditState extends State { SizedBox(width: 50, child: Text(label)), Expanded( child: TextFormField( - controller: controller, - validator: (val) => val?.isNotEmpty == true ? null : "", - keyboardType: keyboardType, - decoration: InputDecoration( - hintText: hint, - contentPadding: const EdgeInsets.all(10), - errorStyle: const TextStyle(height: 0, fontSize: 0), - focusedBorder: focusedBorder(), - isDense: true, - border: const OutlineInputBorder()), - )) + controller: controller, + validator: (val) => val?.isNotEmpty == true ? null : "", + keyboardType: keyboardType, + decoration: InputDecoration( + hintText: hint, + contentPadding: const EdgeInsets.all(10), + errorStyle: const TextStyle(height: 0, fontSize: 0), + focusedBorder: focusedBorder(), + isDense: true, + border: const OutlineInputBorder()), + )) ]); } InputBorder focusedBorder() { - return OutlineInputBorder(borderSide: BorderSide(color: Theme.of(context).colorScheme.primary, width: 2)); + return OutlineInputBorder(borderSide: BorderSide(color: Theme + .of(context) + .colorScheme + .primary, width: 2)); } } @@ -279,41 +273,103 @@ class ScriptList extends StatefulWidget { } class _ScriptListState extends State { - int selected = -1; + Set selected = {}; + bool multiple = false; AppLocalizations get localizations => AppLocalizations.of(context)!; @override Widget build(BuildContext context) { - return Column(children: rows(widget.scripts)); + return Scaffold( + persistentFooterButtons: [multiple ? globalMenu() : const SizedBox()], + body: Container( + padding: const EdgeInsets.only(top: 10, bottom: 30), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2))), + child: Scrollbar( + child: ListView(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container(width: 100, padding: const EdgeInsets.only(left: 10), child: Text(localizations.name)), + SizedBox(width: 50, child: Text(localizations.enable, textAlign: TextAlign.center)), + const VerticalDivider(), + const Expanded(child: Text("URL")), + ], + ), + const Divider(thickness: 0.5), + Column(children: rows(widget.scripts)) + ])))); + } + + globalMenu() { + return Stack(children: [ + Container( + height: 50, + width: double.infinity, + margin: const EdgeInsets.only(top: 10), + decoration: BoxDecoration(border: Border.all(color: Colors.grey.withOpacity(0.2)))), + Positioned( + top: 0, + left: 0, + right: 0, + child: Center( + child: TextButton( + onPressed: () {}, + child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + TextButton.icon( + onPressed: () { + export(selected.toList()); + setState(() { + selected.clear(); + multiple = false; + }); + }, + icon: const Icon(Icons.share, size: 18), + label: Text(localizations.export, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () => removeScripts(selected.toList()), + icon: const Icon(Icons.delete, size: 18), + label: Text(localizations.delete, style: const TextStyle(fontSize: 14))), + TextButton.icon( + onPressed: () { + setState(() { + multiple = false; + selected.clear(); + }); + }, + icon: const Icon(Icons.cancel, size: 18), + label: Text(localizations.cancel, style: const TextStyle(fontSize: 14))), + ])))) + ]); } List rows(List list) { - var primaryColor = Theme.of(context).colorScheme.primary; + var primaryColor = Theme + .of(context) + .colorScheme + .primary; return List.generate(list.length, (index) { return InkWell( splashColor: primaryColor.withOpacity(0.3), onTap: () async { - String script = await (await ScriptManager.instance).getScript(list[index]); - if (!mounted) { + if (multiple) { + setState(() { + if (!selected.add(index)) { + selected.remove(index); + } + }); return; } - Navigator.of(context) - .push(MaterialPageRoute(builder: (context) => ScriptEdit(scriptItem: list[index], script: script))) - .then((value) { - if (value != null) { - setState(() {}); - } - }); + showEdit(index); }, onLongPress: () => showMenus(index), child: Container( - color: selected == index + color: selected.contains(index) ? primaryColor.withOpacity(0.8) : index.isEven - ? Colors.grey.withOpacity(0.1) - : null, + ? Colors.grey.withOpacity(0.1) + : null, height: 45, padding: const EdgeInsets.all(5), child: Row( @@ -342,8 +398,9 @@ class _ScriptListState extends State { //点击菜单 showMenus(int index) { setState(() { - selected = index; + selected.add(index); }); + showModalBottomSheet( context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(10))), @@ -353,27 +410,14 @@ class _ScriptListState extends State { alignment: WrapAlignment.center, children: [ BottomSheetItem( - text: localizations.edit, - onPressed: () async { - String script = await (await ScriptManager.instance).getScript(widget.scripts[index]); - if (!context.mounted) { - return; - } - Navigator.of(context) - .push(MaterialPageRoute( - builder: (context) => ScriptEdit(scriptItem: widget.scripts[index], script: script))) - .then((value) { - if (value != null) { - setState(() {}); - } - }); + text: localizations.multiple, + onPressed: () { + setState(() => multiple = true); }), const Divider(thickness: 0.5, height: 1), - BottomSheetItem( - text: localizations.share, - onPressed: () { - export(widget.scripts[index]); - }), + BottomSheetItem(text: localizations.edit, onPressed: () => showEdit(index)), + const Divider(thickness: 0.5, height: 1), + BottomSheetItem(text: localizations.share, onPressed: () => export([index])), const Divider(thickness: 0.5, height: 1), BottomSheetItem( text: widget.scripts[index].enabled ? localizations.disabled : localizations.enable, @@ -389,7 +433,9 @@ class _ScriptListState extends State { _refreshScript(); if (context.mounted) FlutterToastr.show(localizations.importSuccess, context); }), - Container(color: Theme.of(context).hoverColor, height: 8), + Container(color: Theme + .of(context) + .hoverColor, height: 8), TextButton( child: Container( height: 50, @@ -404,19 +450,67 @@ class _ScriptListState extends State { ); }).then((value) { setState(() { - selected = -1; + selected.remove(index); }); }); } + showEdit([int? index]) async { + String? script = index == null ? null : await (await ScriptManager.instance).getScript(widget.scripts[index]); + if (!mounted) { + return; + } + Navigator.of(context) + .push(MaterialPageRoute( + builder: (context) => ScriptEdit(scriptItem: index == null ? null : widget.scripts[index], script: script))) + .then((value) { + if (value != null) { + setState(() {}); + } + }); + } + //导出js - export(ScriptItem item) async { + export(List indexes) async { + if (indexes.isEmpty) return; //文件名称 - String fileName = '${item.name}.json'; - var json = item.toJson(); - json.remove("scriptPath"); - json['script'] = await (await ScriptManager.instance).getScript(item); + String fileName = 'proxypin-scripts.json'; + var scriptManager = await ScriptManager.instance; + List json = []; + for (var idx in indexes) { + var item = widget.scripts[idx]; + var map = item.toJson(); + map.remove("scriptPath"); + map['script'] = await scriptManager.getScript(item); + json.add(map); + } + final XFile file = XFile.fromData(utf8.encode(jsonEncode(json)), mimeType: 'json'); Share.shareXFiles([file], subject: fileName); } + + enableStatus(bool enable) { + for (var idx in selected) { + widget.scripts[idx].enabled = enable; + } + setState(() {}); + _refreshScript(); + } + + removeScripts(List indexes) async { + if (indexes.isEmpty) return; + showConfirmDialog(context, content: localizations.confirmContent, onConfirm: () async { + var scriptManager = await ScriptManager.instance; + for (var idx in indexes) { + await scriptManager.removeScript(idx); + } + + setState(() { + selected.clear(); + }); + _refreshScript(); + + if (mounted) FlutterToastr.show(localizations.deleteSuccess, context); + }); + } }