From 36d2b8f381bf04f5bea70a5b37b46025addb86f4 Mon Sep 17 00:00:00 2001 From: wanghongenpin Date: Wed, 20 Dec 2023 20:36:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=89=E5=8D=93=E5=B0=8F=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 35 ++--- .../kotlin/com/network/proxy/MainActivity.kt | 16 +++ .../com/network/proxy/ProxyVpnService.kt | 52 ++++++- .../proxy/plugin/AndroidFlutterPlugin.kt | 2 +- .../proxy/plugin/AppLifecyclePlugin.kt | 26 ++++ .../proxy/plugin/PictureInPicturePlugin.kt | 123 ++++++++++++++++ .../network/proxy/plugin/VpnServicePlugin.kt | 14 +- .../network/proxy/vpn/socket/ProtectSocket.kt | 15 ++ .../proxy/vpn/socket/ProtectSocketHolder.kt | 32 +++++ lib/native/app_lifecycle.dart | 5 + lib/native/pip.dart | 23 +++ lib/native/vpn.dart | 4 + lib/network/bin/configuration.dart | 5 + lib/ui/component/utils.dart | 2 +- lib/ui/desktop/desktop.dart | 4 +- lib/ui/launch/launch.dart | 30 ++-- lib/ui/mobile/menu.dart | 134 ++++++++++++------ lib/ui/mobile/mobile.dart | 102 ++++++++----- lib/ui/mobile/request/history.dart | 9 +- lib/ui/mobile/request/list.dart | 34 ++++- lib/ui/mobile/setting/theme.dart | 29 ++-- lib/ui/ui_configuration.dart | 3 + linux/build.sh | 2 +- 23 files changed, 554 insertions(+), 147 deletions(-) create mode 100644 android/app/src/main/kotlin/com/network/proxy/plugin/AppLifecyclePlugin.kt create mode 100644 android/app/src/main/kotlin/com/network/proxy/plugin/PictureInPicturePlugin.kt create mode 100644 android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocket.kt create mode 100644 android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocketHolder.kt create mode 100644 lib/native/app_lifecycle.dart create mode 100644 lib/native/pip.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 97264eb..362437f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,40 +1,43 @@ - + - + - + + android:icon="@mipmap/ic_launcher" + android:label="ProxyPin"> + android:launchMode="singleTop" + android:supportsPictureInPicture="true" + android:theme="@style/LaunchTheme" + android:windowSoftInputMode="adjustResize" + tools:targetApi="n"> + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + + + android:exported="true" + android:permission="android.permission.BIND_VPN_SERVICE"> 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 c706638..4b11b64 100644 --- a/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt +++ b/android/app/src/main/kotlin/com/network/proxy/MainActivity.kt @@ -1,14 +1,18 @@ package com.network.proxy +import android.app.PictureInPictureParams import android.content.Intent import android.net.VpnService import android.os.Bundle +import com.network.proxy.plugin.AppLifecyclePlugin +import com.network.proxy.plugin.PictureInPicturePlugin import com.network.proxy.plugin.VpnServicePlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine class MainActivity : FlutterActivity() { + private val lifecycleChannel: AppLifecyclePlugin = AppLifecyclePlugin() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -20,11 +24,23 @@ class MainActivity : FlutterActivity() { pluginRegister(flutterEngine) } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + lifecycleChannel.onUserLeaveHint() + } + + override fun onResume() { + super.onResume() + lifecycleChannel.onResume() + } + /** * 注册插件 */ private fun pluginRegister(flutterEngine: FlutterEngine) { flutterEngine.plugins.add(VpnServicePlugin()) + flutterEngine.plugins.add(PictureInPicturePlugin()) + flutterEngine.plugins.add(lifecycleChannel) } /** diff --git a/android/app/src/main/kotlin/com/network/proxy/ProxyVpnService.kt b/android/app/src/main/kotlin/com/network/proxy/ProxyVpnService.kt index 50af240..79bd2d7 100644 --- a/android/app/src/main/kotlin/com/network/proxy/ProxyVpnService.kt +++ b/android/app/src/main/kotlin/com/network/proxy/ProxyVpnService.kt @@ -4,18 +4,21 @@ import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.content.Context import android.content.Intent import android.net.ProxyInfo import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import androidx.core.app.NotificationCompat +import com.network.proxy.vpn.socket.ProtectSocket +import com.network.proxy.vpn.socket.ProtectSocketHolder /** * VPN服务 * @author wanghongen */ -class ProxyVpnService : VpnService() { +class ProxyVpnService : VpnService(), ProtectSocket { private var vpnInterface: ParcelFileDescriptor? = null companion object { @@ -35,6 +38,27 @@ class ProxyVpnService : VpnService() { */ private const val NOTIFICATION_ID = 9527 const val VPN_NOTIFICATION_CHANNEL_ID = "vpn-notifications" + + var isRunning = false + + fun stopVpnIntent(context: Context): Intent { + return Intent(context, ProxyVpnService::class.java).also { + it.action = ACTION_DISCONNECT + } + } + + fun startVpnIntent( + context: Context, + proxyHost: String? = null, + proxyPort: Int? = null, + allowApps: ArrayList? = null + ): Intent { + return Intent(context, ProxyVpnService::class.java).also { + it.putExtra(ProxyHost, proxyHost) + it.putExtra(ProxyPort, proxyPort) + it.putStringArrayListExtra(AllowApps, allowApps) + } + } } override fun onDestroy() { @@ -42,14 +66,15 @@ class ProxyVpnService : VpnService() { disconnect() } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - return if (intent?.action == ACTION_DISCONNECT) { + override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + return if (intent.action == ACTION_DISCONNECT) { disconnect() START_NOT_STICKY } else { connect( - intent?.getStringExtra(ProxyHost)!!, intent.getIntExtra(ProxyPort, 0), - intent.getStringArrayListExtra(AllowApps) + intent.getStringExtra(ProxyHost) ?: this.host!!, + intent.getIntExtra(ProxyPort, this.port), + intent.getStringArrayListExtra(AllowApps) ?: this.allowApps ) START_STICKY } @@ -61,9 +86,13 @@ class ProxyVpnService : VpnService() { stopForeground(STOP_FOREGROUND_REMOVE) } vpnInterface = null + isRunning = false } private fun connect(proxyHost: String, proxyPort: Int, allowPackages: List?) { + this.host = proxyHost + this.port = proxyPort + this.allowApps = allowPackages vpnInterface = createVpnInterface(proxyHost, proxyPort, allowPackages) if (vpnInterface == null) { val alertDialog = Intent(applicationContext, VpnAlertDialog::class.java) @@ -72,7 +101,10 @@ class ProxyVpnService : VpnService() { startActivity(alertDialog) return } + + ProtectSocketHolder.setProtectSocket(this) showServiceNotification() + isRunning = true } private fun showServiceNotification() { @@ -110,6 +142,7 @@ class ProxyVpnService : VpnService() { .addAddress("10.0.0.2", 32) .addRoute("0.0.0.0", 0) .setSession(baseContext.applicationInfo.name) + .setBlocking(true) val packages = allowPackages?.filter { it != baseContext.packageName } if (packages?.isNotEmpty() == true) { @@ -120,6 +153,15 @@ class ProxyVpnService : VpnService() { build.addDisallowedApplication(baseContext.packageName) } + build.setConfigureIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE + ) + ) + return build.apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setHttpProxy(ProxyInfo.buildDirectProxy(proxyHost, proxyPort)) diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/AndroidFlutterPlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/AndroidFlutterPlugin.kt index a87560c..81f9079 100644 --- a/android/app/src/main/kotlin/com/network/proxy/plugin/AndroidFlutterPlugin.kt +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/AndroidFlutterPlugin.kt @@ -16,7 +16,7 @@ abstract class AndroidFlutterPlugin : FlutterPlugin, ActivityAware { } override fun onAttachedToActivity(binding: ActivityPluginBinding) { - activity = binding.activity; + activity = binding.activity } override fun onDetachedFromActivityForConfigChanges() { diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/AppLifecyclePlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/AppLifecyclePlugin.kt new file mode 100644 index 0000000..f609a4c --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/AppLifecyclePlugin.kt @@ -0,0 +1,26 @@ +package com.network.proxy.plugin + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodChannel + +class AppLifecyclePlugin : AndroidFlutterPlugin() { + var channel: MethodChannel? = null + + companion object { + const val CHANNEL = "com.proxy/appLifecycle" + + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel = MethodChannel(binding.binaryMessenger, CHANNEL) + } + + fun onUserLeaveHint() { + channel?.invokeMethod("onUserLeaveHint", null) + } + + fun onResume() { + channel?.invokeMethod("onResume", null) + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/PictureInPicturePlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/PictureInPicturePlugin.kt new file mode 100644 index 0000000..2d64421 --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/PictureInPicturePlugin.kt @@ -0,0 +1,123 @@ +package com.network.proxy.plugin + +import android.app.PendingIntent +import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import android.os.Build +import android.util.Rational +import com.network.proxy.ProxyVpnService +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodChannel +import android.util.Log +import androidx.core.content.ContextCompat + +/** + * 画中画插件 + */ +class PictureInPicturePlugin : AndroidFlutterPlugin() { + private var registerBroadcast = false + + ///广播事件接受者 + private val vpnBroadcastReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + Log.d("com.network.proxy", "onReceive ${intent?.action}") + + if (context == null || intent?.action != VPN_ACTION) { + return + } + val isRunning = ProxyVpnService.isRunning + + if (isRunning) { + activity.startService(ProxyVpnService.stopVpnIntent(activity)) + } else { + activity.startService(ProxyVpnService.startVpnIntent(activity)) + } + + //设置画中画参数 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + updatePictureInPictureParams(!isRunning) + } + } + } + + companion object { + const val CHANNEL = "com.proxy/pictureInPicture" + const val VPN_ACTION = "VPN_ACTION" + } + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val channel = MethodChannel(binding.binaryMessenger, CHANNEL) + channel.setMethodCallHandler { call, result -> + when (call.method) { + "enterPictureInPictureMode" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val param = updatePictureInPictureParams(ProxyVpnService.isRunning) + + if (!registerBroadcast) { + registerBroadcast = true + ContextCompat.registerReceiver( + activity, + vpnBroadcastReceiver, + IntentFilter(VPN_ACTION), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + } + + result.success(activity.enterPictureInPictureMode(param)) + } + } + + else -> { + result.notImplemented() + } + } + } + } + + // 画中画参数 + private fun updatePictureInPictureParams(isRunning: Boolean): PictureInPictureParams { + + val params = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PictureInPictureParams.Builder() + .setAspectRatio(Rational(8, 19)) + .apply { + setActions(listOf(action(isRunning))) //vpn服务运行中,显示停止按钮 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setSeamlessResizeEnabled(false) + } + } + .build() + } else { + throw RuntimeException("getPictureInPictureParams error") + } + activity.setPictureInPictureParams(params) + return params + } + + //停止vpn服务 RemoteAction + private fun action(isRunning: Boolean): RemoteAction { + val pIntent: PendingIntent = PendingIntent.getBroadcast( + activity, + if (isRunning) 0 else 1, + Intent(VPN_ACTION), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_IMMUTABLE else PendingIntent.FLAG_UPDATE_CURRENT + ) + + //vpn服务运行中,显示停止按钮 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return RemoteAction( + Icon.createWithResource( + this@PictureInPicturePlugin.activity, + if (isRunning) android.R.drawable.ic_media_pause else android.R.drawable.ic_media_play + ), "Proxy", "Proxy", pIntent + ) + } else { + throw RuntimeException("action error") + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/plugin/VpnServicePlugin.kt b/android/app/src/main/kotlin/com/network/proxy/plugin/VpnServicePlugin.kt index 67ddaee..ab9de71 100644 --- a/android/app/src/main/kotlin/com/network/proxy/plugin/VpnServicePlugin.kt +++ b/android/app/src/main/kotlin/com/network/proxy/plugin/VpnServicePlugin.kt @@ -1,6 +1,5 @@ package com.network.proxy.plugin -import android.content.Intent import android.util.Log import com.network.proxy.ProxyVpnService import io.flutter.embedding.engine.plugins.FlutterPlugin @@ -14,9 +13,11 @@ class VpnServicePlugin : AndroidFlutterPlugin() { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { val channel = MethodChannel(binding.binaryMessenger, CHANNEL) - channel.setMethodCallHandler { call, result -> when (call.method) { + "isRunning" -> { + result.success(ProxyVpnService.isRunning) + } "startVpn" -> { val host = call.argument("proxyHost") val port = call.argument("proxyPort") @@ -48,10 +49,7 @@ class VpnServicePlugin : AndroidFlutterPlugin() { */ private fun startVpn(host: String, port: Int, allowApps: ArrayList?) { Log.i("com.network.proxy", "startVpn $host:$port $allowApps") - val intent = Intent(activity, ProxyVpnService::class.java) - intent.putExtra(ProxyVpnService.ProxyHost, host) - intent.putExtra(ProxyVpnService.ProxyPort, port) - intent.putStringArrayListExtra(ProxyVpnService.AllowApps, allowApps) + val intent = ProxyVpnService.startVpnIntent(activity, host, port, allowApps) activity.startService(intent) } @@ -59,8 +57,6 @@ class VpnServicePlugin : AndroidFlutterPlugin() { * 停止vpn服务 */ private fun stopVpn() { - activity.startService(Intent(activity, ProxyVpnService::class.java).also { - it.action = ProxyVpnService.ACTION_DISCONNECT - }) + activity.startService(ProxyVpnService.stopVpnIntent(activity)) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocket.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocket.kt new file mode 100644 index 0000000..a547ad8 --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocket.kt @@ -0,0 +1,15 @@ +package com.network.proxy.vpn.socket + +import java.net.DatagramSocket +import java.net.Socket + +interface ProtectSocket { + + /** + * 保护Socket不受VPN连接的影响。保护后,通过该套接字发送的数据将直接进入底层网络,因此其流量不会通过VPN转发。 + */ + fun protect(socket: Socket): Boolean + + fun protect(socket: DatagramSocket): Boolean + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocketHolder.kt b/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocketHolder.kt new file mode 100644 index 0000000..ef86daf --- /dev/null +++ b/android/app/src/main/kotlin/com/network/proxy/vpn/socket/ProtectSocketHolder.kt @@ -0,0 +1,32 @@ +package com.network.proxy.vpn.socket + +import java.net.DatagramSocket +import java.net.Socket + +/** + * ProtectSocket的持有者,用于在VPNService中获取ProtectSocket的实例 + */ +class ProtectSocketHolder { + + companion object { + private var protectSocket: ProtectSocket? = null + + fun setProtectSocket(protectSocket: ProtectSocket) { + this.protectSocket = protectSocket + } + + fun getProtectSocket(): ProtectSocket? { + return protectSocket + } + + fun protect(socket: Socket): Boolean { + return protectSocket?.protect(socket) ?: false + } + + fun protect(socket: DatagramSocket): Boolean { + return protectSocket?.protect(socket) ?: false + } + } + + +} \ No newline at end of file diff --git a/lib/native/app_lifecycle.dart b/lib/native/app_lifecycle.dart new file mode 100644 index 0000000..3da3168 --- /dev/null +++ b/lib/native/app_lifecycle.dart @@ -0,0 +1,5 @@ +import 'package:flutter/material.dart'; + +abstract interface class AppLifecycleListener { + void onUserLeaveHint(AppLifecycleState state); +} diff --git a/lib/native/pip.dart b/lib/native/pip.dart new file mode 100644 index 0000000..9881d5e --- /dev/null +++ b/lib/native/pip.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +///画中画 +class PictureInPicture { + static const MethodChannel _channel = MethodChannel('com.proxy/pictureInPicture'); + + ///进入画中画模式 + static Future enterPictureInPictureMode() async { + if (Platform.isAndroid) { + final bool enterPictureInPictureMode = await _channel.invokeMethod('enterPictureInPictureMode'); + return enterPictureInPictureMode; + } + return false; + } + + ///退出画中画模式 + static Future exitPictureInPictureMode() async { + final bool exitPictureInPictureMode = await _channel.invokeMethod('exitPictureInPictureMode'); + return exitPictureInPictureMode; + } +} diff --git a/lib/native/vpn.dart b/lib/native/vpn.dart index 71d49e0..e0cc15a 100644 --- a/lib/native/vpn.dart +++ b/lib/native/vpn.dart @@ -20,4 +20,8 @@ class Vpn { proxyVpnChannel.invokeMethod("restartVpn", {"proxyHost": host, "proxyPort": port, "allowApps": appList}); isVpnStarted = true; } + + static Future isRunning() async { + return await proxyVpnChannel.invokeMethod("isRunning"); + } } diff --git a/lib/network/bin/configuration.dart b/lib/network/bin/configuration.dart index a937cf7..f6c4227 100644 --- a/lib/network/bin/configuration.dart +++ b/lib/network/bin/configuration.dart @@ -45,6 +45,9 @@ class Configuration { //白名单应用 List appWhitelist = []; + /// 是否启用小窗口 + bool smallWindow = false; + //远程连接 不持久化保存 String? remoteHost; @@ -79,6 +82,7 @@ class Configuration { appWhitelist = List.from(config['appWhitelist'] ?? []); HostFilter.whitelist.load(config['whitelist']); HostFilter.blacklist.load(config['blacklist']); + smallWindow = config['smallWindow'] ?? Platform.isAndroid; } /// 配置文件 @@ -126,6 +130,7 @@ class Configuration { 'appWhitelist': appWhitelist, 'whitelist': HostFilter.whitelist.toJson(), 'blacklist': HostFilter.blacklist.toJson(), + 'smallWindow': smallWindow, }; } } diff --git a/lib/ui/component/utils.dart b/lib/ui/component/utils.dart index b3302af..1448a32 100644 --- a/lib/ui/component/utils.dart +++ b/lib/ui/component/utils.dart @@ -34,7 +34,7 @@ String getPackagesSize(HttpRequest request, HttpResponse? response) { if (responsePackage.isEmpty) { return package; } - return "$package / $package "; + return "$package / $responsePackage "; } String getPackage(HttpMessage? message) { diff --git a/lib/ui/desktop/desktop.dart b/lib/ui/desktop/desktop.dart index adb6543..e7a183d 100644 --- a/lib/ui/desktop/desktop.dart +++ b/lib/ui/desktop/desktop.dart @@ -144,12 +144,12 @@ class _DesktopHomePagePageState extends State implements EventL content: const Text( '提示:默认不会开启HTTPS抓包,请安装证书后再开启HTTPS抓包。\n' '点击的HTTPS抓包(加锁图标),选择安装根证书,按照提示操作即可。\n\n' - '1. 请求重写增加 修改请求,可根据增则替换;\n' + '1. 请求重写增加 修改请求,可根据正则替换;\n' '2. 请求重写批量导入、导出;\n' '3. 支持WebSocket抓包;\n' '4. 优化curl导入;\n' '5. 支持head请求,修复手机端请求重写切换应用恢复原始的请求问题;\n' - ';', + '', style: TextStyle(fontSize: 14))); }); } diff --git a/lib/ui/launch/launch.dart b/lib/ui/launch/launch.dart index 3f83611..efed879 100644 --- a/lib/ui/launch/launch.dart +++ b/lib/ui/launch/launch.dart @@ -7,9 +7,11 @@ import 'package:network_proxy/utils/platform.dart'; import 'package:window_manager/window_manager.dart'; class SocketLaunch extends StatefulWidget { + static bool started = false; + final ProxyServer proxyServer; final int size; - final bool startup; + final bool startup; //默认是否启动 final Function? onStart; final Function? onStop; @@ -31,14 +33,13 @@ class SocketLaunch extends StatefulWidget { } class _SocketLaunchState extends State with WindowListener, WidgetsBindingObserver { - static bool started = false; - @override void initState() { super.initState(); windowManager.addListener(this); WidgetsBinding.instance.addObserver(this); //启动代理服务器 + print("SocketLaunch ${widget.startup}"); if (widget.startup) { start(); } @@ -58,34 +59,35 @@ class _SocketLaunchState extends State with WindowListener, Widget void onWindowClose() async { await widget.proxyServer.stop(); print("onWindowClose"); - started = false; + SocketLaunch.started = false; await windowManager.destroy(); exit(0); } @override void didChangeAppLifecycleState(AppLifecycleState state) { - super.didChangeAppLifecycleState(state); if (state == AppLifecycleState.detached) { print('AppLifecycleState.detached'); widget.onStop?.call(); widget.proxyServer.stop(); - started = false; + SocketLaunch.started = false; } } @override Widget build(BuildContext context) { + print("SocketLaunch build ${widget.startup}"); + return IconButton( - tooltip: started ? "停止" : "启动", - icon: Icon(started ? Icons.stop : Icons.play_arrow_sharp, - color: started ? Colors.red : Colors.green, size: widget.size.toDouble()), + tooltip: SocketLaunch.started ? "停止" : "启动", + icon: Icon(SocketLaunch.started ? Icons.stop : Icons.play_arrow_sharp, + color: SocketLaunch.started ? Colors.red : Colors.green, size: widget.size.toDouble()), onPressed: () async { - if (started) { + if (SocketLaunch.started) { if (!widget.serverLaunch) { setState(() { widget.onStop?.call(); - started = !started; + SocketLaunch.started = !SocketLaunch.started; }); return; } @@ -93,7 +95,7 @@ class _SocketLaunchState extends State with WindowListener, Widget widget.proxyServer.stop().then((value) { widget.onStop?.call(); setState(() { - started = !started; + SocketLaunch.started = !SocketLaunch.started; }); }); } else { @@ -107,14 +109,14 @@ class _SocketLaunchState extends State with WindowListener, Widget if (!widget.serverLaunch) { setState(() { widget.onStart?.call(); - started = true; + SocketLaunch.started = true; }); return; } widget.proxyServer.start().then((value) { setState(() { - started = true; + SocketLaunch.started = true; }); widget.onStart?.call(); }).catchError((e) { diff --git a/lib/ui/mobile/menu.dart b/lib/ui/mobile/menu.dart index a74d888..6d79ed7 100644 --- a/lib/ui/mobile/menu.dart +++ b/lib/ui/mobile/menu.dart @@ -6,14 +6,14 @@ import 'package:flutter_barcode_scanner/flutter_barcode_scanner.dart'; import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:network_proxy/native/vpn.dart'; import 'package:network_proxy/network/bin/server.dart'; -import 'package:network_proxy/network/http_client.dart'; import 'package:network_proxy/network/components/host_filter.dart'; import 'package:network_proxy/network/components/request_rewrite_manager.dart'; +import 'package:network_proxy/network/http_client.dart'; import 'package:network_proxy/ui/component/toolbox.dart'; +import 'package:network_proxy/ui/component/widgets.dart'; import 'package:network_proxy/ui/mobile/connect_remote.dart'; import 'package:network_proxy/ui/mobile/request/favorite.dart'; import 'package:network_proxy/ui/mobile/request/history.dart'; -import 'package:network_proxy/ui/mobile/request/list.dart'; import 'package:network_proxy/ui/mobile/setting/app_whitelist.dart'; import 'package:network_proxy/ui/mobile/setting/filter.dart'; import 'package:network_proxy/ui/mobile/setting/proxy.dart'; @@ -29,9 +29,8 @@ import 'package:url_launcher/url_launcher.dart'; ///左侧抽屉 class DrawerWidget extends StatelessWidget { final ProxyServer proxyServer; - final GlobalKey requestStateKey; - const DrawerWidget({super.key, required this.proxyServer, required this.requestStateKey}); + const DrawerWidget({super.key, required this.proxyServer}); @override Widget build(BuildContext context) { @@ -46,73 +45,124 @@ class DrawerWidget extends StatelessWidget { ListTile( leading: const Icon(Icons.favorite), title: const Text("收藏"), - trailing: const Icon(Icons.arrow_right), onTap: () => navigator(context, MobileFavorites(proxyServer: proxyServer))), ListTile( leading: const Icon(Icons.history), title: const Text("历史"), - trailing: const Icon(Icons.arrow_right), - onTap: () => navigator(context, MobileHistory(proxyServer: proxyServer, requestStateKey: requestStateKey)), + onTap: () => navigator(context, MobileHistory(proxyServer: proxyServer)), ), const Divider(thickness: 0.3), - ListTile( - title: const Text("代理"), - trailing: const Icon(Icons.arrow_right), - onTap: () => navigator(context, ProxySetting(proxyServer: proxyServer))), ListTile( title: const Text("HTTPS抓包"), - trailing: const Icon(Icons.arrow_right), + leading: const Icon(Icons.https), onTap: () => navigator(context, MobileSslWidget(proxyServer: proxyServer))), - const MobileThemeSetting(), - Platform.isIOS - ? const SizedBox() - : ListTile( - title: const Text("应用白名单"), - trailing: const Icon(Icons.arrow_right), - onTap: () => navigator(context, AppWhitelist(proxyServer: proxyServer))), ListTile( - title: const Text("域名白名单"), - trailing: const Icon(Icons.arrow_right), - onTap: () => navigator( - context, MobileFilterWidget(configuration: proxyServer.configuration, hostList: HostFilter.whitelist))), - ListTile( - title: const Text("域名黑名单"), - trailing: const Icon(Icons.arrow_right), - onTap: () => navigator( - context, MobileFilterWidget(configuration: proxyServer.configuration, hostList: HostFilter.blacklist))), + title: const Text("过滤"), + leading: const Icon(Icons.filter_alt_outlined), + onTap: () => navigator(context, FilterMenu(proxyServer: proxyServer))), ListTile( title: const Text("请求重写"), - trailing: const Icon(Icons.arrow_right), + leading: const Icon(Icons.replay_outlined), onTap: () async => navigator(context, MobileRequestRewrite(requestRewrites: (await RequestRewrites.instance)))), ListTile( title: const Text("脚本"), - trailing: const Icon(Icons.arrow_right), + leading: const Icon(Icons.code), onTap: () => navigator(context, const MobileScript())), + ListTile( + title: const Text("设置"), + leading: const Icon(Icons.settings), + onTap: () => navigator(context, SettingMenu(proxyServer: proxyServer))), ListTile( title: const Text("关于"), - trailing: const Icon(Icons.arrow_right), + leading: const Icon(Icons.info_outline), onTap: () => navigator(context, const About())), ], )); } +} - ///跳转页面 - navigator(BuildContext context, Widget widget) { - Navigator.of(context).push( - MaterialPageRoute(builder: (BuildContext context) { - return widget; - }), - ); +///跳转页面 +navigator(BuildContext context, Widget widget) { + Navigator.of(context).push( + MaterialPageRoute(builder: (BuildContext context) { + return widget; + }), + ); +} + +///设置 +class SettingMenu extends StatelessWidget { + final ProxyServer proxyServer; + + const SettingMenu({super.key, required this.proxyServer}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("设置", style: TextStyle(fontSize: 16)), centerTitle: true), + body: Padding( + padding: const EdgeInsets.all(5), + child: ListView(children: [ + ListTile( + title: const Text("代理"), + trailing: const Icon(Icons.arrow_right), + onTap: () => navigator(context, ProxySetting(proxyServer: proxyServer))), + const MobileThemeSetting(), + Platform.isIOS + ? const SizedBox() + : ListTile( + title: const Text("窗口模式"), + subtitle: const Text("开启抓包后 如果应用退回到后台,显示一个小窗口", style: TextStyle(fontSize: 12)), + trailing: SwitchWidget( + value: proxyServer.configuration.smallWindow, + onChanged: (value) { + proxyServer.configuration.smallWindow = value; + proxyServer.configuration.flushConfig(); + })), + ]))); + } +} + +///抓包过滤菜单 +class FilterMenu extends StatelessWidget { + final ProxyServer proxyServer; + + const FilterMenu({super.key, required this.proxyServer}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text("过滤", style: TextStyle(fontSize: 16)), centerTitle: true), + body: Padding( + padding: const EdgeInsets.all(5), + child: ListView(children: [ + ListTile( + title: const Text("域名白名单"), + trailing: const Icon(Icons.arrow_right), + onTap: () => navigator(context, + MobileFilterWidget(configuration: proxyServer.configuration, hostList: HostFilter.whitelist))), + ListTile( + title: const Text("域名黑名单"), + trailing: const Icon(Icons.arrow_right), + onTap: () => navigator(context, + MobileFilterWidget(configuration: proxyServer.configuration, hostList: HostFilter.blacklist))), + Platform.isIOS + ? const SizedBox() + : ListTile( + title: const Text("应用白名单"), + trailing: const Icon(Icons.arrow_right), + onTap: () => navigator(context, AppWhitelist(proxyServer: proxyServer))), + ]))); } } /// +号菜单 -class MoreEnum extends StatelessWidget { +class MoreMenu extends StatelessWidget { final ProxyServer proxyServer; final ValueNotifier desktop; - const MoreEnum({super.key, required this.proxyServer, required this.desktop}); + const MoreMenu({super.key, required this.proxyServer, required this.desktop}); @override Widget build(BuildContext context) { @@ -262,9 +312,7 @@ class MoreEnum extends StatelessWidget { } } -/** - * 关于 - */ +/// 关于 class About extends StatelessWidget { const About({super.key}); diff --git a/lib/ui/mobile/mobile.dart b/lib/ui/mobile/mobile.dart index cfe605f..76dc50b 100644 --- a/lib/ui/mobile/mobile.dart +++ b/lib/ui/mobile/mobile.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:network_proxy/native/pip.dart'; import 'package:network_proxy/native/vpn.dart'; import 'package:network_proxy/network/bin/configuration.dart'; import 'package:network_proxy/network/bin/server.dart'; @@ -17,6 +18,7 @@ import 'package:network_proxy/ui/mobile/connect_remote.dart'; import 'package:network_proxy/ui/mobile/menu.dart'; import 'package:network_proxy/ui/mobile/request/list.dart'; import 'package:network_proxy/ui/mobile/request/search.dart'; +import 'package:network_proxy/ui/ui_configuration.dart'; import 'package:network_proxy/utils/ip.dart'; class MobileHomePage extends StatefulWidget { @@ -30,12 +32,33 @@ class MobileHomePage extends StatefulWidget { } } -class MobileHomeState extends State implements EventListener { +class MobileHomeState extends State with WidgetsBindingObserver implements EventListener { final GlobalKey requestStateKey = GlobalKey(); late ProxyServer proxyServer; ValueNotifier desktop = ValueNotifier(RemoteModel(connect: false)); + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + print("didChangeAppLifecycleState $state"); + + if (state == AppLifecycleState.inactive && Vpn.isVpnStarted) { + if (desktop.value.connect || !Platform.isAndroid || !widget.configuration.smallWindow) { + return; + } + + PictureInPicture.enterPictureInPictureMode().then((value) => pictureInPictureNotifier.value = value); + } + + if (state == AppLifecycleState.resumed && pictureInPictureNotifier.value) { + Vpn.isRunning().then((value) { + Vpn.isVpnStarted = value; + print("isRunning $value"); + pictureInPictureNotifier.value = false; + }); + } + } + @override void onRequest(Channel channel, HttpRequest request) { requestStateKey.currentState!.add(channel, request); @@ -56,6 +79,8 @@ class MobileHomeState extends State implements EventListener { @override void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); proxyServer = ProxyServer(widget.configuration); proxyServer.addListener(this); proxyServer.start(); @@ -70,7 +95,6 @@ class MobileHomeState extends State implements EventListener { } }); - super.initState(); if (widget.configuration.upgradeNoticeV6) { WidgetsBinding.instance.addPostFrameCallback((_) { showUpgradeNotice(); @@ -81,49 +105,59 @@ class MobileHomeState extends State implements EventListener { @override void dispose() { desktop.dispose(); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: MobileSearch(onSearch: (val) { - requestStateKey.currentState?.search(val); - }), - actions: [ - IconButton( - tooltip: "清理", - icon: const Icon(Icons.cleaning_services_outlined), - onPressed: () => requestStateKey.currentState?.clean()), - const SizedBox(width: 2), - MoreEnum(proxyServer: proxyServer, desktop: desktop), - const SizedBox(width: 10) - ]), - drawer: DrawerWidget(proxyServer: proxyServer, requestStateKey: requestStateKey), - floatingActionButton: FloatingActionButton( - onPressed: null, - child: Center( - child: futureWidget( - localIp(), - (data) => SocketLaunch( + return ValueListenableBuilder( + valueListenable: pictureInPictureNotifier, + builder: (context, pip, _) { + if (pip) { + return Scaffold(body: RequestListWidget(key: requestStateKey, proxyServer: proxyServer)); + } + + return Scaffold( + appBar: AppBar( + title: MobileSearch(onSearch: (val) { + requestStateKey.currentState?.search(val); + }), + actions: [ + IconButton( + tooltip: "清理", + icon: const Icon(Icons.cleaning_services_outlined), + onPressed: () => requestStateKey.currentState?.clean()), + const SizedBox(width: 2), + MoreMenu(proxyServer: proxyServer, desktop: desktop), + const SizedBox(width: 10) + ]), + drawer: DrawerWidget(proxyServer: proxyServer), + floatingActionButton: FloatingActionButton( + onPressed: null, + child: Center( + child: futureWidget(localIp(), (data) { + SocketLaunch.started = Vpn.isVpnStarted; + return SocketLaunch( proxyServer: proxyServer, size: 36, startup: false, serverLaunch: false, onStart: () => Vpn.startVpn(Platform.isAndroid ? data : "127.0.0.1", proxyServer.port, proxyServer.configuration.appWhitelist), - onStop: () => Vpn.stopVpn()))), - ), - body: ValueListenableBuilder( - valueListenable: desktop, - builder: (context, value, _) { - return Column(children: [ - value.connect ? remoteConnect(value) : const SizedBox(), - Expanded(child: RequestListWidget(key: requestStateKey, proxyServer: proxyServer)) - ]); - }), - ); + onStop: () => Vpn.stopVpn()); + })), + ), + body: ValueListenableBuilder( + valueListenable: desktop, + builder: (context, value, _) { + return Column(children: [ + value.connect ? remoteConnect(value) : const SizedBox(), + Expanded(child: RequestListWidget(key: requestStateKey, proxyServer: proxyServer)) + ]); + }), + ); + }); } showUpgradeNotice() { diff --git a/lib/ui/mobile/request/history.dart b/lib/ui/mobile/request/history.dart index 072fd41..15193bc 100644 --- a/lib/ui/mobile/request/history.dart +++ b/lib/ui/mobile/request/history.dart @@ -22,9 +22,8 @@ import '../../../utils/har.dart'; class MobileHistory extends StatefulWidget { final ProxyServer proxyServer; - final GlobalKey requestStateKey; - const MobileHistory({super.key, required this.proxyServer, required this.requestStateKey}); + const MobileHistory({super.key, required this.proxyServer}); @override State createState() { @@ -42,10 +41,10 @@ class _MobileHistoryState extends State { return futureWidget(HistoryStorage.instance, (data) { List children = []; - var container = widget.requestStateKey.currentState?.container; - if (container?.isNotEmpty == true && !_sessionSaved) { + var container = RequestListState.container; + if (container.isNotEmpty == true && !_sessionSaved) { //当前会话未保存,是否保存当前会话 - children.add(buildSaveSession(data, container!)); + children.add(buildSaveSession(data, container)); } var histories = data.histories; diff --git a/lib/ui/mobile/request/list.dart b/lib/ui/mobile/request/list.dart index b8cc1f5..900f823 100644 --- a/lib/ui/mobile/request/list.dart +++ b/lib/ui/mobile/request/list.dart @@ -6,11 +6,12 @@ import 'package:flutter_toastr/flutter_toastr.dart'; import 'package:network_proxy/network/bin/configuration.dart'; import 'package:network_proxy/network/bin/server.dart'; import 'package:network_proxy/network/channel.dart'; +import 'package:network_proxy/network/components/host_filter.dart'; import 'package:network_proxy/network/host_port.dart'; import 'package:network_proxy/network/http/http.dart'; -import 'package:network_proxy/network/components/host_filter.dart'; import 'package:network_proxy/ui/desktop/left/model/search_model.dart'; import 'package:network_proxy/ui/mobile/request/request.dart'; +import 'package:network_proxy/ui/ui_configuration.dart'; class RequestListWidget extends StatefulWidget { final ProxyServer proxyServer; @@ -34,7 +35,7 @@ class RequestListState extends State { final GlobalKey domainListKey = GlobalKey(); //请求列表容器 - List container = []; + static List container = []; @override void initState() { @@ -44,13 +45,25 @@ class RequestListState extends State { } } - @override - void dispose() { - super.dispose(); - } - @override Widget build(BuildContext context) { + if (pictureInPictureNotifier.value) { + if (container.isEmpty) { + return const Center(child: Text("暂无请求", style: TextStyle(color: Colors.grey))); + } + + return ListView.separated( + padding: const EdgeInsets.only(left: 2), + itemCount: container.length, + separatorBuilder: (context, index) => const Divider(thickness: 0.2, height: 0.5), + itemBuilder: (context, index) { + return Text.rich( + overflow: TextOverflow.ellipsis, + TextSpan(text: container[container.length - index - 1].requestUrl, style: const TextStyle(fontSize: 8)), + maxLines: 2); + }); + } + return DefaultTabController( length: tabs.length, child: Scaffold( @@ -67,6 +80,13 @@ class RequestListState extends State { ///添加请求 add(Channel channel, HttpRequest request) { + if (pictureInPictureNotifier.value) { + setState(() { + container.add(request); + }); + return; + } + container.add(request); requestSequenceKey.currentState?.add(request); domainListKey.currentState?.add(request); diff --git a/lib/ui/mobile/setting/theme.dart b/lib/ui/mobile/setting/theme.dart index 9d6eb25..1a03bfa 100644 --- a/lib/ui/mobile/setting/theme.dart +++ b/lib/ui/mobile/setting/theme.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:network_proxy/main.dart'; class MobileThemeSetting extends StatelessWidget { - const MobileThemeSetting({Key? key}) : super(key: key); + const MobileThemeSetting({super.key}); @override Widget build(BuildContext context) { @@ -30,21 +30,32 @@ class MobileThemeSetting extends StatelessWidget { onTap: () { themeNotifier.value = themeNotifier.value.copy(mode: ThemeMode.system); }), - PopupMenuItem( - child: const ListTile(trailing: Icon(Icons.nightlight_outlined), dense: true, title: Text("深色")), - onTap: () { - themeNotifier.value = themeNotifier.value.copy(mode: ThemeMode.dark); - }), PopupMenuItem( child: const ListTile(trailing: Icon(Icons.sunny), dense: true, title: Text("浅色")), onTap: () { themeNotifier.value = themeNotifier.value.copy(mode: ThemeMode.light); }), + PopupMenuItem( + child: const ListTile(trailing: Icon(Icons.nightlight_outlined), dense: true, title: Text("深色")), + onTap: () { + themeNotifier.value = themeNotifier.value.copy(mode: ThemeMode.dark); + }), ]; }, - child: const ListTile( - title: Text("主题"), - trailing: Icon(Icons.arrow_right), + child: ListTile( + title: const Text("主题"), + trailing: getIcon(), )); } + + Icon getIcon() { + switch (themeNotifier.value.mode) { + case ThemeMode.system: + return const Icon(Icons.cached); + case ThemeMode.dark: + return const Icon(Icons.nightlight_outlined); + case ThemeMode.light: + return const Icon(Icons.sunny); + } + } } diff --git a/lib/ui/ui_configuration.dart b/lib/ui/ui_configuration.dart index 4f18251..2f301f4 100644 --- a/lib/ui/ui_configuration.dart +++ b/lib/ui/ui_configuration.dart @@ -6,6 +6,9 @@ import 'package:network_proxy/main.dart'; import 'package:network_proxy/utils/platform.dart'; import 'package:path_provider/path_provider.dart'; +///画中画 + ValueNotifier pictureInPictureNotifier = ValueNotifier(false); + class UIConfiguration { ThemeModel theme = ThemeModel(); diff --git a/linux/build.sh b/linux/build.sh index 9202cd5..d1ea4a5 100644 --- a/linux/build.sh +++ b/linux/build.sh @@ -4,7 +4,7 @@ cd ../build/linux/x64/release rm -rf package mkdir -p package/DEBIAN echo "Package: ProxyPin" >> package/DEBIAN/control -echo "Version: 1.0.5" >> package/DEBIAN/control +echo "Version: 1.0.6" >> package/DEBIAN/control echo "Priority: optional" >> package/DEBIAN/control echo "Architecture: amd64" >> package/DEBIAN/control echo "Depends: ca-certificates" >> package/DEBIAN/control