diff --git a/README.md b/README.md index c7eb711..f8739ad 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ # Wow English * flutter版本号:3.19.2 + +## Android 使用 Whistle 抓包 + +为了让 Dio 的 HTTPS 请求能够经过 Whistle 解密,Android Debug 包默认允许任意服务器证书,不需要在项目中内置或指定 Whistle Root CA。普通 Release 包仍使用系统默认的严格证书校验。 + +### 构建方式 + +Debug 包默认允许 Whistle 抓包: + +```bash +flutter build apk --debug +``` + +构建允许 Whistle 抓包的 Release 包: + +```bash +flutter build apk --release --dart-define=ENABLE_WHISTLE_CA=true +``` + +构建正常生产 Release 包(不允许 Whistle 抓包): + +```bash +flutter build apk --release +``` + +手机通过 Clash Meta 将流量转发到运行 Whistle 的电脑后,即可抓取 Dio 发出的 HTTPS 请求。因为抓包构建不绑定具体 CA,所以更换 Whistle 实例或重新生成 Root CA 后无需修改代码和重新导入证书。 + +> Debug 包以及带 `ENABLE_WHISTLE_CA=true` 的 Release 包会接受任意 HTTPS 证书,无法抵御中间人攻击。此类 APK 只应在内部测试设备和测试人员之间分发,不能作为正式生产包发布。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 757c149..bf89af0 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -109,5 +109,5 @@ dependencies { // kotlin扩展(可选) implementation 'com.geyifeng.immersionbar:immersionbar-ktx:3.2.2' // coco2d游戏 - implementation 'io.keyss.android.library:steve_game:1.0.1' + implementation 'com.ishow.android:steve_game:1.0.1' } diff --git a/android/build.gradle b/android/build.gradle index 42499bb..f2c94fc 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,3 +1,5 @@ +apply from: "/Users/stay/StudioProjects/ishowMavenRepositories.gradle" + buildscript { ext.kotlin_version = '1.8.21' repositories { @@ -29,6 +31,8 @@ allprojects { } } +project.ext.setIshowMavenRepositories(project) + rootProject.buildDir = '../build' subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" diff --git a/assets/images/ic_customer_service.png b/assets/images/ic_customer_service.png new file mode 100644 index 0000000..195da4a --- /dev/null +++ b/assets/images/ic_customer_service.png diff --git a/lib/common/request/request_client.dart b/lib/common/request/request_client.dart index 848c9b3..e3ad890 100644 --- a/lib/common/request/request_client.dart +++ b/lib/common/request/request_client.dart @@ -1,6 +1,8 @@ import 'dart:convert'; +import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import 'package:wow_english/utils/toast_util.dart'; @@ -17,14 +19,32 @@ part 'apis.dart'; RequestClient requestClient = RequestClient(); class RequestClient { + static const bool _enableWhistleCaptureInRelease = bool.fromEnvironment( + 'ENABLE_WHISTLE_CA', + defaultValue: false, + ); + late Dio _dio; RequestClient() { - _dio = Dio(BaseOptions(baseUrl: RequestConfig.baseUrl, connectTimeout: RequestConfig.connectTimeout)); + _dio = Dio(BaseOptions( + baseUrl: RequestConfig.baseUrl, + connectTimeout: RequestConfig.connectTimeout)); _dio.interceptors.add(TokenInterceptor()); if (kDebugMode) { - _dio.interceptors - .add(PrettyDioLogger(requestHeader: true, requestBody: true, responseHeader: true, maxWidth: 120)); + _dio.interceptors.add(PrettyDioLogger( + requestHeader: true, + requestBody: true, + responseHeader: true, + maxWidth: 120)); + } + + // 仅供内部抓包构建使用;启用后会接受任意 HTTPS 证书。 + if (Platform.isAndroid && (kDebugMode || _enableWhistleCaptureInRelease)) { + _dio.httpClientAdapter = IOHttpClientAdapter( + createHttpClient: () => + HttpClient()..badCertificateCallback = (_, __, ___) => true, + ); } } @@ -79,7 +99,11 @@ class RequestClient { bool Function(ApiException)? onError, }) { return request(url, - method: 'GET', queryParameters: queryParameters, headers: headers, onResponse: onResponse, onError: onError); + method: 'GET', + queryParameters: queryParameters, + headers: headers, + onResponse: onResponse, + onError: onError); } /// post diff --git a/lib/generated/json/app_config_entity.g.dart b/lib/generated/json/app_config_entity.g.dart index 82091e3..360f3f9 100644 --- a/lib/generated/json/app_config_entity.g.dart +++ b/lib/generated/json/app_config_entity.g.dart @@ -7,20 +7,37 @@ AppConfigEntity $AppConfigEntityFromJson(Map json) { if (safe != null) { appConfigEntity.safe = safe; } + final List? customerMobileList = + (json['customerMobileList'] as List?) + ?.map((e) => jsonConvert.convert(e) as String) + .toList(); + if (customerMobileList != null) { + appConfigEntity.customerMobileList = customerMobileList; + } + final String? customerQr = jsonConvert.convert(json['customerQr']); + if (customerQr != null) { + appConfigEntity.customerQr = customerQr; + } return appConfigEntity; } Map $AppConfigEntityToJson(AppConfigEntity entity) { final Map data = {}; data['safe'] = entity.safe; + data['customerMobileList'] = entity.customerMobileList; + data['customerQr'] = entity.customerQr; return data; } extension AppConfigEntityExtension on AppConfigEntity { AppConfigEntity copyWith({ String? safe, + List? customerMobileList, + String? customerQr, }) { return AppConfigEntity() - ..safe = safe ?? this.safe; + ..safe = safe ?? this.safe + ..customerMobileList = customerMobileList ?? this.customerMobileList + ..customerQr = customerQr ?? this.customerQr; } } \ No newline at end of file diff --git a/lib/models/app_config_entity.dart b/lib/models/app_config_entity.dart index 97487cc..a7f4d8c 100644 --- a/lib/models/app_config_entity.dart +++ b/lib/models/app_config_entity.dart @@ -6,24 +6,29 @@ import '../generated/json/app_config_entity.g.dart'; @JsonSerializable() class AppConfigEntity { + // 当前是否安全,safe-安全 otherwise-隐藏pay + String? safe; - // 当前是否安全,safe-安全 otherwise-隐藏pay - String? safe; + // 客服手机号列表 + List customerMobileList = []; + // 客服二维码图片地址 + String? customerQr; - AppConfigEntity(); + AppConfigEntity(); - factory AppConfigEntity.fromJson(Map json) => $AppConfigEntityFromJson(json); + factory AppConfigEntity.fromJson(Map json) => + $AppConfigEntityFromJson(json); - Map toJson() => $AppConfigEntityToJson(this); + Map toJson() => $AppConfigEntityToJson(this); - @override - String toString() { - return jsonEncode(this); - } + @override + String toString() { + return jsonEncode(this); + } - // 是否审核中(null或者非"safe"即不安全) - bool isAppReviewing() { - return safe != "safe"; - } + // 是否审核中(null或者非"safe"即不安全) + bool isAppReviewing() { + return safe != "safe"; + } } \ No newline at end of file diff --git a/lib/pages/home/widgets/BaseHomeHeaderWidget.dart b/lib/pages/home/widgets/BaseHomeHeaderWidget.dart index 0db3d8e..28d517d 100644 --- a/lib/pages/home/widgets/BaseHomeHeaderWidget.dart +++ b/lib/pages/home/widgets/BaseHomeHeaderWidget.dart @@ -10,6 +10,7 @@ import '../../../models/course_entity.dart'; import '../../../route/route.dart'; import '../../../utils/image_util.dart'; import '../../user/bloc/user_bloc.dart'; +import 'customer_service_qr_dialog.dart'; typedef HeaderCallback = void Function(dynamic); @@ -80,6 +81,28 @@ class BaseHomeHeaderWidget extends StatelessWidget { textAlign: TextAlign.left, style: TextStyle(color: Colors.white, fontSize: 30.0), )), + FutureBuilder( + future: AppConfigHelper.getAppConfig(), + builder: (context, snapshot) { + final customerQr = snapshot.data?.customerQr?.trim() ?? ''; + if (customerQr.isEmpty) { + return const SizedBox.shrink(); + } + return GestureDetector( + onTap: () => + showCustomerServiceQrDialog(context, customerQr), + child: Padding( + padding: EdgeInsets.only(right: 12.w), + child: Image.asset( + 'ic_customer_service'.assetPng, + width: 90.w, + height: 28.h, + fit: BoxFit.contain, + ), + ), + ); + }, + ), Offstage( offstage: AppConfigHelper.shouldHidePay() || !UserUtil.isLogined(), diff --git a/lib/pages/home/widgets/customer_service_qr_dialog.dart b/lib/pages/home/widgets/customer_service_qr_dialog.dart new file mode 100644 index 0000000..f4a1a60 --- /dev/null +++ b/lib/pages/home/widgets/customer_service_qr_dialog.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +Future showCustomerServiceQrDialog( + BuildContext context, + String imageUrl, +) { + return showDialog( + context: context, + barrierColor: Colors.black54, + barrierDismissible: true, + builder: (context) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 10.h), + child: ClipRRect( + borderRadius: BorderRadius.circular(12.r), + child: SizedBox( + height: 340.h, + child: AspectRatio( + aspectRatio: 870 / 1110, + child: Image.network( + imageUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + loadingBuilder: (context, child, progress) { + if (progress == null) { + return child; + } + return Container( + color: Colors.white, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ); + }, + errorBuilder: (context, error, stackTrace) => Container( + color: Colors.white, + alignment: Alignment.center, + child: Text( + '图片加载失败', + style: TextStyle( + color: Colors.grey[600], + fontSize: 14.sp, + ), + ), + ), + ), + ), + ), + ), + ), + ); +} diff --git a/lib/pages/shop/home/shop_home_page.dart b/lib/pages/shop/home/shop_home_page.dart index e37ed76..83fd7e9 100644 --- a/lib/pages/shop/home/shop_home_page.dart +++ b/lib/pages/shop/home/shop_home_page.dart @@ -5,7 +5,6 @@ import 'package:wow_english/common/extension/string_extension.dart'; import 'package:wow_english/common/widgets/we_app_bar.dart'; import 'package:wow_english/pages/shop/home/widgets/product_item.dart'; import 'package:wow_english/route/route.dart'; -import 'package:wow_english/utils/toast_util.dart'; import 'bloc/shop_home_bloc.dart'; diff --git a/lib/pages/shop/home/widgets/product_item.dart b/lib/pages/shop/home/widgets/product_item.dart index 727007f..5f58797 100644 --- a/lib/pages/shop/home/widgets/product_item.dart +++ b/lib/pages/shop/home/widgets/product_item.dart @@ -67,10 +67,13 @@ class ProductItem extends StatelessWidget { ), ), GestureDetector( + behavior: HitTestBehavior.opaque, onTap: () { onTap(); }, child: Container( + width: double.infinity, + constraints: BoxConstraints(minHeight: 28.h), decoration: BoxDecoration( color: const Color(0xFFF5C51F), borderRadius: BorderRadius.circular(5.r), @@ -78,17 +81,14 @@ class ProductItem extends StatelessWidget { color: const Color(0xFF333333), width: 1.0, )), - padding: EdgeInsets.symmetric( - vertical: 1.h - ), + padding: EdgeInsets.symmetric(vertical: 4.h), child: Center( child: Text( '立即购买', style: TextStyle( fontSize: 10.sp, color: const Color(0xFF333333)), ), - ) - ), + )), ) ], ), diff --git a/lib/pages/user/user_page.dart b/lib/pages/user/user_page.dart index cd0ff09..f0b7f4a 100644 --- a/lib/pages/user/user_page.dart +++ b/lib/pages/user/user_page.dart @@ -13,6 +13,7 @@ import 'package:wow_english/models/user_entity.dart'; import 'package:wow_english/pages/user/bloc/user_bloc.dart'; import 'package:wow_english/route/route.dart'; import 'package:wow_english/utils/image_util.dart'; +import 'package:wow_english/utils/toast_util.dart'; class UserPage extends StatelessWidget { const UserPage({super.key}); @@ -196,6 +197,16 @@ class _UserView extends StatelessWidget { child: 12.verticalSpace, ), OutlinedButton( + onPressed: () { + _showCustomerServiceDialog(context); + }, + style: normalButtonStyle, + child: Text( + "联系客服", + style: textStyle21sp, + )), + 12.verticalSpace, + OutlinedButton( onPressed: () => pushNamed(AppRouteName.fogPwd), style: normalButtonStyle, child: Text( @@ -232,16 +243,6 @@ class _UserView extends StatelessWidget { 12.verticalSpace, OutlinedButton( onPressed: () { - _showTeacherSelectionDialog(context); - }, - style: normalButtonStyle, - child: Text( - "联系客服", - style: textStyle21sp, - )), - 12.verticalSpace, - OutlinedButton( - onPressed: () { pushNamed(AppRouteName.setting); }, style: normalButtonStyle, @@ -308,7 +309,33 @@ class _UserView extends StatelessWidget { }, ); - void _showTeacherSelectionDialog(BuildContext context) { + Future _showCustomerServiceDialog(BuildContext context) async { + try { + final config = await AppConfigHelper.getAppConfig(); + if (!context.mounted) { + return; + } + + final mobileList = config?.customerMobileList + .map((mobile) => mobile.trim()) + .where((mobile) => mobile.isNotEmpty) + .toSet() + .toList() ?? + []; + if (mobileList.isEmpty) { + showToast('暂无客服联系方式'); + return; + } + _showMobileSelectionDialog(context, mobileList); + } catch (_) { + if (context.mounted) { + showToast('客服联系方式获取失败,请稍后重试'); + } + } + } + + void _showMobileSelectionDialog( + BuildContext context, List mobileList) { showModalBottomSheet( context: context, backgroundColor: Colors.white, @@ -333,30 +360,28 @@ class _UserView extends StatelessWidget { borderRadius: BorderRadius.circular(2), ), ), - // 老师选项列表 + // 客服手机号列表 Flexible( child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, - children: [ - // 恐龙老师选项 - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + children: + List.generate(mobileList.length * 2 - 1, (index) { + if (index.isOdd) { + return const Divider(height: 1); + } + final mobile = mobileList[index ~/ 2]; + return ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), title: Text( - '恐龙老师', + mobile, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: Colors.black87, ), ), - subtitle: Text( - '19357119913', - style: TextStyle( - fontSize: 12.sp, - color: Colors.grey[600], - ), - ), trailing: Icon( Icons.phone, color: Colors.green[600], @@ -364,39 +389,10 @@ class _UserView extends StatelessWidget { ), onTap: () { Navigator.pop(context); - _launchPhone('tel:+8619357119913'); + _launchPhone(mobile); }, - ), - const Divider(height: 1), - // Rose老师选项 - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - title: Text( - 'Rose老师', - style: TextStyle( - fontSize: 14.sp, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - ), - subtitle: Text( - '19033986279', - style: TextStyle( - fontSize: 12.sp, - color: Colors.grey[600], - ), - ), - trailing: Icon( - Icons.phone, - color: Colors.green[600], - size: 18, - ), - onTap: () { - Navigator.pop(context); - _launchPhone('tel:+8619033986279'); - }, - ), - ], + ); + }), ), ), ), @@ -433,8 +429,9 @@ class _UserView extends StatelessWidget { } void _launchPhone(String phone) async { - if (await canLaunchUrl(Uri.parse(phone))) { - await launchUrl(Uri.parse(phone)); + final phoneUri = Uri(scheme: 'tel', path: phone); + if (await canLaunchUrl(phoneUri)) { + await launchUrl(phoneUri); } else { throw 'Could not phone $phone'; } diff --git a/pubspec.yaml b/pubspec.yaml index f268af0..a990365 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.11+11 +version: 1.0.13+13 environment: sdk: '>=3.2.0 <4.0.0'