Files
proxypin/lib/ui/mobile/mobile.dart

487 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_toastr/flutter_toastr.dart';
import 'package:proxypin/native/app_lifecycle.dart';
import 'package:proxypin/native/pip.dart';
import 'package:proxypin/native/vpn.dart';
import 'package:proxypin/network/bin/configuration.dart';
import 'package:proxypin/network/bin/server.dart';
import 'package:proxypin/network/channel.dart';
import 'package:proxypin/network/handler.dart';
import 'package:proxypin/network/http/http.dart';
import 'package:proxypin/network/http/websocket.dart';
import 'package:proxypin/network/http_client.dart';
import 'package:proxypin/ui/component/memory_cleanup.dart';
import 'package:proxypin/ui/component/toolbox.dart';
import 'package:proxypin/ui/configuration.dart';
import 'package:proxypin/ui/content/panel.dart';
import 'package:proxypin/ui/launch/launch.dart';
import 'package:proxypin/ui/mobile/menu/drawer.dart';
import 'package:proxypin/ui/mobile/menu/me.dart';
import 'package:proxypin/ui/mobile/menu/menu.dart';
import 'package:proxypin/ui/mobile/request/list.dart';
import 'package:proxypin/ui/mobile/request/search.dart';
import 'package:proxypin/ui/mobile/widgets/pip.dart';
import 'package:proxypin/ui/mobile/widgets/remote_device.dart';
import 'package:proxypin/utils/ip.dart';
import 'package:proxypin/utils/lang.dart';
import 'package:proxypin/utils/listenable_list.dart';
import 'package:proxypin/utils/navigator.dart';
///移动端首页
///@author wanghongen
class MobileHomePage extends StatefulWidget {
final Configuration configuration;
final AppConfiguration appConfiguration;
const MobileHomePage(this.configuration, this.appConfiguration, {super.key});
@override
State<StatefulWidget> createState() {
return MobileHomeState();
}
}
class MobileApp {
///请求列表key
static final GlobalKey<RequestListState> requestStateKey = GlobalKey<RequestListState>();
///搜索key
static final GlobalKey<MobileSearchState> searchStateKey = GlobalKey<MobileSearchState>();
///请求列表容器
static final container = ListenableList<HttpRequest>();
}
class MobileHomeState extends State<MobileHomePage> implements EventListener, LifecycleListener {
/// 选择索引
final ValueNotifier<int> _selectIndex = ValueNotifier(0);
late ProxyServer proxyServer;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void onRequest(Channel channel, HttpRequest request) {
MobileApp.requestStateKey.currentState!.add(channel, request);
PictureInPicture.addData(request.requestUrl);
//监控内存 到达阈值清理
MemoryCleanupMonitor.onMonitor(onCleanup: () {
MobileApp.requestStateKey.currentState?.cleanupEarlyData(32);
});
}
@override
void onResponse(ChannelContext channelContext, HttpResponse response) {
MobileApp.requestStateKey.currentState!.addResponse(channelContext, response);
}
@override
void onMessage(Channel channel, HttpMessage message, WebSocketFrame frame) {
var panel = NetworkTabController.current;
if (panel?.request.get() == message || panel?.response.get() == message) {
panel?.changeState();
}
}
@override
void initState() {
super.initState();
AppLifecycleBinding.instance.addListener(this);
proxyServer = ProxyServer(widget.configuration);
proxyServer.addListener(this);
proxyServer.start();
if (widget.appConfiguration.upgradeNoticeV15) {
WidgetsBinding.instance.addPostFrameCallback((_) {
showUpgradeNotice();
});
}
}
@override
void dispose() {
AppLifecycleBinding.instance.removeListener(this);
super.dispose();
}
int exitTime = 0;
var requestPageNavigatorKey = GlobalKey<NavigatorState>();
var toolboxNavigatorKey = GlobalKey<NavigatorState>();
var mePageNavigatorKey = GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
var navigationView = [
NavigatorPage(
navigatorKey: requestPageNavigatorKey,
child: RequestPage(proxyServer: proxyServer, appConfiguration: widget.appConfiguration)),
NavigatorPage(
navigatorKey: toolboxNavigatorKey,
child: Scaffold(
appBar: PreferredSize(
preferredSize: const Size.fromHeight(42),
child: AppBar(
title: Text(localizations.toolbox,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w400)),
centerTitle: true)),
body: Toolbox(proxyServer: proxyServer))),
NavigatorPage(navigatorKey: mePageNavigatorKey, child: MePage(proxyServer: proxyServer)),
];
if (!widget.appConfiguration.bottomNavigation) _selectIndex.value = 0;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
return;
}
if (navigationView[_selectIndex.value].onPopInvoked()) {
return;
}
if (await enterPictureInPicture()) {
return;
}
if (DateTime.now().millisecondsSinceEpoch - exitTime > 1500) {
exitTime = DateTime.now().millisecondsSinceEpoch;
if (mounted) {
FlutterToastr.show(localizations.appExitTips, this.context,
rootNavigator: true, duration: FlutterToastr.lengthLong);
}
return;
}
//退出程序
SystemNavigator.pop();
},
child: ValueListenableBuilder<int>(
valueListenable: _selectIndex,
builder: (context, index, child) => Scaffold(
body: IndexedStack(index: index, children: navigationView),
bottomNavigationBar: widget.appConfiguration.bottomNavigation
? Container(
constraints: const BoxConstraints(maxHeight: 80),
child: Theme(
data: Theme.of(context).copyWith(splashColor: Colors.transparent),
child: BottomNavigationBar(
selectedIconTheme: const IconThemeData(size: 27),
unselectedIconTheme: const IconThemeData(size: 27),
selectedFontSize: 0,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.workspaces), label: localizations.requests),
BottomNavigationBarItem(
icon: const Icon(Icons.construction), label: localizations.toolbox),
BottomNavigationBarItem(icon: const Icon(Icons.person), label: localizations.me),
],
currentIndex: _selectIndex.value,
onTap: (index) => _selectIndex.value = index,
),
))
: null)));
}
@override
void onUserLeaveHint() {
enterPictureInPicture();
}
Future<bool> enterPictureInPicture() async {
if (Vpn.isVpnStarted) {
if (!Platform.isAndroid || !(await (AppConfiguration.instance)).pipEnabled.value) {
return false;
}
List<String>? appList =
proxyServer.configuration.appWhitelistEnabled ? proxyServer.configuration.appWhitelist : [];
List<String>? disallowApps;
if (appList.isEmpty) {
disallowApps = proxyServer.configuration.appBlacklist ?? [];
}
return PictureInPicture.enterPictureInPictureMode(
Platform.isAndroid ? await localIp() : "127.0.0.1", proxyServer.port,
appList: appList, disallowApps: disallowApps);
}
return false;
}
@override
onPictureInPictureModeChanged(bool isInPictureInPictureMode) async {
if (isInPictureInPictureMode) {
Navigator.push(
context,
PageRouteBuilder(
transitionDuration: Duration.zero,
pageBuilder: (context, animation, secondaryAnimation) {
return PictureInPictureWindow(MobileApp.container);
}));
return;
}
if (!isInPictureInPictureMode) {
Navigator.maybePop(context);
Vpn.isRunning().then((value) {
Vpn.isVpnStarted = value;
SocketLaunch.startStatus.value = ValueWrap.of(value);
});
}
}
showUpgradeNotice() {
bool isCN = Localizations.localeOf(context) == const Locale.fromSubtags(languageCode: 'zh');
String content = isCN
? '提示默认不会开启HTTPS抓包请安装证书后再开启HTTPS抓包。\n\n'
'1. 请求重写升级UI优化, 请求修改增加匹配数据查看;\n'
'2. 请求弹出菜单UI优化, 支持请求高亮;\n'
'3. 脚本内置File Api, 支持文件读取、写入等操作, 详细查看wiki文档\n'
"4. 脚本内置MD5方法, md5('xxx')\n"
'5. 支持内存自动清理设置, 到内存限制自动清理请求;\n'
'6. 工具箱增加正则表达式, 支持匹配数据替换;\n'
'7. ios支持生成新根证书, 生成需要重新安装根证书;\n'
'8. 修复暗黑模式icon展示不清晰\n'
: 'TipsBy default, HTTPS packet capture will not be enabled. Please install the certificate before enabling HTTPS packet capture。\n\n'
'Click HTTPS Capture packets(Lock icon)Choose to install the root certificate and follow the prompts to proceed。\n\n'
'1. Request to rewrite and upgrade UI optimization, request to modify and add matching data viewing\n'
'2. Request pop-up menu UI optimization, support request highlighting\n'
'3. The script has built-in File Api, which supports file reading, writing and other operations. For details, please refer to the wiki document\n'
"4. The script has built-in MD5 method, md5('xxx')\n"
'5. Support memory automatic cleanup settings, memory limit automatic cleanup requests\n'
'6. Toolbox adds regular expressions to support matching data replacement\n'
'7. iOS supports generating new root certificates, which requires reinstalling the root certificate\n'
'8. Fixed unclear display of dark mode icon\n'
'';
showAlertDialog(isCN ? '更新内容V1.1.5' : "Update content V1.1.5", content, () {
widget.appConfiguration.upgradeNoticeV15 = false;
widget.appConfiguration.flushConfig();
});
}
showAlertDialog(String title, String content, Function onClose) {
showDialog(
context: context,
barrierDismissible: false,
builder: (context) {
return AlertDialog(
scrollable: true,
actions: [
TextButton(
onPressed: () {
onClose.call();
Navigator.pop(context);
},
child: Text(localizations.cancel))
],
title: Text(title, style: const TextStyle(fontSize: 18)),
content: SelectableText(content));
});
}
}
class RequestPage extends StatefulWidget {
final ProxyServer proxyServer;
final AppConfiguration appConfiguration;
const RequestPage({super.key, required this.proxyServer, required this.appConfiguration});
@override
State<RequestPage> createState() => RequestPageState();
}
class RequestPageState extends State<RequestPage> {
/// 远程连接
final ValueNotifier<RemoteModel> remoteDevice = ValueNotifier(RemoteModel(connect: false));
late ProxyServer proxyServer;
AppLocalizations get localizations => AppLocalizations.of(context)!;
@override
void initState() {
super.initState();
proxyServer = widget.proxyServer;
//远程连接
remoteDevice.addListener(() {
if (remoteDevice.value.connect) {
proxyServer.configuration.remoteHost = "http://${remoteDevice.value.host}:${remoteDevice.value.port}";
checkConnectTask(context);
} else {
proxyServer.configuration.remoteHost = null;
}
});
}
@override
void dispose() {
remoteDevice.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(children: [
Scaffold(
appBar: _MobileAppBar(widget.appConfiguration, proxyServer, remoteDevice: remoteDevice),
drawer: widget.appConfiguration.bottomNavigation
? null
: DrawerWidget(proxyServer: proxyServer, container: MobileApp.container),
floatingActionButton: _launchActionButton(),
body: ValueListenableBuilder(
valueListenable: remoteDevice,
builder: (context, value, _) {
return Column(children: [
value.connect ? remoteConnect(value) : const SizedBox(),
Expanded(
child: RequestListWidget(
key: MobileApp.requestStateKey, proxyServer: proxyServer, list: MobileApp.container))
]);
}),
),
PictureInPictureIcon(proxyServer),
]);
}
Widget _launchActionButton() {
var theme = Theme.of(context);
return Theme(
data: ThemeData.from(colorScheme: theme.colorScheme, textTheme: theme.textTheme, useMaterial3: true),
child: FloatingActionButton(
onPressed: null,
backgroundColor: theme.colorScheme.primaryContainer,
child: SocketLaunch(
proxyServer: proxyServer,
size: 36,
startup: proxyServer.configuration.startup,
serverLaunch: false,
onStart: () async {
String host = Platform.isAndroid ? await localIp(readCache: false) : "127.0.0.1";
int port = proxyServer.port;
if (Platform.isIOS) {
await proxyServer.retryBind();
}
if (remoteDevice.value.ipProxy == true) {
host = remoteDevice.value.host!;
port = remoteDevice.value.port!;
}
Vpn.startVpn(host, port, proxyServer.configuration, ipProxy: remoteDevice.value.ipProxy);
},
onStop: () => Vpn.stopVpn()),
));
}
/// 远程连接
Widget remoteConnect(RemoteModel value) {
return Container(
margin: const EdgeInsets.only(top: 5, bottom: 5),
height: 56,
width: double.infinity,
child: ElevatedButton(
style: ButtonStyle(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)))),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(builder: (BuildContext context) {
return RemoteDevicePage(remoteDevice: remoteDevice, proxyServer: proxyServer);
})),
child: Text(localizations.remoteConnected(remoteDevice.value.os ?? ', ${remoteDevice.value.hostname}'),
style: Theme.of(context).textTheme.titleMedium),
));
}
/// 检查远程连接
checkConnectTask(BuildContext context) async {
int retry = 0;
Timer.periodic(const Duration(milliseconds: 15000), (timer) async {
if (remoteDevice.value.connect == false) {
timer.cancel();
return;
}
try {
var response = await HttpClients.get("http://${remoteDevice.value.host}:${remoteDevice.value.port}/ping")
.timeout(const Duration(seconds: 3));
if (response.bodyAsString == "pong") {
retry = 0;
return;
}
} catch (e) {
retry++;
}
if (retry > 3) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(localizations.remoteConnectDisconnect),
action: SnackBarAction(
label: localizations.disconnect,
onPressed: () {
timer.cancel();
remoteDevice.value = RemoteModel(connect: false);
})));
}
}
});
}
}
/// 移动端AppBar
class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
final AppConfiguration appConfiguration;
final ProxyServer proxyServer;
final ValueNotifier<RemoteModel> remoteDevice;
const _MobileAppBar(this.appConfiguration, this.proxyServer, {required this.remoteDevice});
@override
Size get preferredSize => const Size.fromHeight(42);
@override
Widget build(BuildContext context) {
AppLocalizations localizations = AppLocalizations.of(context)!;
var bottomNavigation = appConfiguration.bottomNavigation;
return AppBar(
leading: bottomNavigation ? const SizedBox() : null,
title: MobileSearch(
key: MobileApp.searchStateKey, onSearch: (val) => MobileApp.requestStateKey.currentState?.search(val)),
actions: [
IconButton(
tooltip: localizations.clear,
icon: const Icon(Icons.cleaning_services_outlined),
onPressed: () => MobileApp.requestStateKey.currentState?.clean()),
const SizedBox(width: 2),
MoreMenu(proxyServer: proxyServer, remoteDevice: remoteDevice),
const SizedBox(width: 10),
]);
}
}