Commit 84656a6ee30c59e452acbd11d6e90b6a19295f77

Authored by xiaoyu
2 parents 618750f4 6770c637

Merge remote-tracking branch 'origin/fix-build-wqf'

README.md
1 1 # Wow English
2 2  
3 3 * flutter版本号:3.19.2
  4 +
  5 +## Android 使用 Whistle 抓包
  6 +
  7 +为了让 Dio 的 HTTPS 请求能够经过 Whistle 解密,Android Debug 包默认允许任意服务器证书,不需要在项目中内置或指定 Whistle Root CA。普通 Release 包仍使用系统默认的严格证书校验。
  8 +
  9 +### 构建方式
  10 +
  11 +Debug 包默认允许 Whistle 抓包:
  12 +
  13 +```bash
  14 +flutter build apk --debug
  15 +```
  16 +
  17 +构建允许 Whistle 抓包的 Release 包:
  18 +
  19 +```bash
  20 +flutter build apk --release --dart-define=ENABLE_WHISTLE_CA=true
  21 +```
  22 +
  23 +构建正常生产 Release 包(不允许 Whistle 抓包):
  24 +
  25 +```bash
  26 +flutter build apk --release
  27 +```
  28 +
  29 +手机通过 Clash Meta 将流量转发到运行 Whistle 的电脑后,即可抓取 Dio 发出的 HTTPS 请求。因为抓包构建不绑定具体 CA,所以更换 Whistle 实例或重新生成 Root CA 后无需修改代码和重新导入证书。
  30 +
  31 +> Debug 包以及带 `ENABLE_WHISTLE_CA=true` 的 Release 包会接受任意 HTTPS 证书,无法抵御中间人攻击。此类 APK 只应在内部测试设备和测试人员之间分发,不能作为正式生产包发布。
... ...
android/app/build.gradle
... ... @@ -109,5 +109,5 @@ dependencies {
109 109 // kotlin扩展(可选)
110 110 implementation 'com.geyifeng.immersionbar:immersionbar-ktx:3.2.2'
111 111 // coco2d游戏
112   - implementation 'io.keyss.android.library:steve_game:1.0.1'
  112 + implementation 'com.ishow.android:steve_game:1.0.1'
113 113 }
... ...
android/build.gradle
  1 +apply from: "/Users/stay/StudioProjects/ishowMavenRepositories.gradle"
  2 +
1 3 buildscript {
2 4 ext.kotlin_version = '1.8.21'
3 5 repositories {
... ... @@ -29,6 +31,8 @@ allprojects {
29 31 }
30 32 }
31 33  
  34 +project.ext.setIshowMavenRepositories(project)
  35 +
32 36 rootProject.buildDir = '../build'
33 37 subprojects {
34 38 project.buildDir = "${rootProject.buildDir}/${project.name}"
... ...
assets/images/ic_customer_service.png 0 → 100644

102 KB

lib/common/request/request_client.dart
1 1 import 'dart:convert';
  2 +import 'dart:io';
2 3  
3 4 import 'package:dio/dio.dart';
  5 +import 'package:dio/io.dart';
4 6 import 'package:flutter/foundation.dart';
5 7 import 'package:pretty_dio_logger/pretty_dio_logger.dart';
6 8 import 'package:wow_english/utils/toast_util.dart';
... ... @@ -17,14 +19,32 @@ part 'apis.dart';
17 19 RequestClient requestClient = RequestClient();
18 20  
19 21 class RequestClient {
  22 + static const bool _enableWhistleCaptureInRelease = bool.fromEnvironment(
  23 + 'ENABLE_WHISTLE_CA',
  24 + defaultValue: false,
  25 + );
  26 +
20 27 late Dio _dio;
21 28  
22 29 RequestClient() {
23   - _dio = Dio(BaseOptions(baseUrl: RequestConfig.baseUrl, connectTimeout: RequestConfig.connectTimeout));
  30 + _dio = Dio(BaseOptions(
  31 + baseUrl: RequestConfig.baseUrl,
  32 + connectTimeout: RequestConfig.connectTimeout));
24 33 _dio.interceptors.add(TokenInterceptor());
25 34 if (kDebugMode) {
26   - _dio.interceptors
27   - .add(PrettyDioLogger(requestHeader: true, requestBody: true, responseHeader: true, maxWidth: 120));
  35 + _dio.interceptors.add(PrettyDioLogger(
  36 + requestHeader: true,
  37 + requestBody: true,
  38 + responseHeader: true,
  39 + maxWidth: 120));
  40 + }
  41 +
  42 + // 仅供内部抓包构建使用;启用后会接受任意 HTTPS 证书。
  43 + if (Platform.isAndroid && (kDebugMode || _enableWhistleCaptureInRelease)) {
  44 + _dio.httpClientAdapter = IOHttpClientAdapter(
  45 + createHttpClient: () =>
  46 + HttpClient()..badCertificateCallback = (_, __, ___) => true,
  47 + );
28 48 }
29 49 }
30 50  
... ... @@ -79,7 +99,11 @@ class RequestClient {
79 99 bool Function(ApiException)? onError,
80 100 }) {
81 101 return request(url,
82   - method: 'GET', queryParameters: queryParameters, headers: headers, onResponse: onResponse, onError: onError);
  102 + method: 'GET',
  103 + queryParameters: queryParameters,
  104 + headers: headers,
  105 + onResponse: onResponse,
  106 + onError: onError);
83 107 }
84 108  
85 109 /// post
... ...
lib/generated/json/app_config_entity.g.dart
... ... @@ -7,20 +7,37 @@ AppConfigEntity $AppConfigEntityFromJson(Map<String, dynamic> json) {
7 7 if (safe != null) {
8 8 appConfigEntity.safe = safe;
9 9 }
  10 + final List<String>? customerMobileList =
  11 + (json['customerMobileList'] as List<dynamic>?)
  12 + ?.map((e) => jsonConvert.convert<String>(e) as String)
  13 + .toList();
  14 + if (customerMobileList != null) {
  15 + appConfigEntity.customerMobileList = customerMobileList;
  16 + }
  17 + final String? customerQr = jsonConvert.convert<String>(json['customerQr']);
  18 + if (customerQr != null) {
  19 + appConfigEntity.customerQr = customerQr;
  20 + }
10 21 return appConfigEntity;
11 22 }
12 23  
13 24 Map<String, dynamic> $AppConfigEntityToJson(AppConfigEntity entity) {
14 25 final Map<String, dynamic> data = <String, dynamic>{};
15 26 data['safe'] = entity.safe;
  27 + data['customerMobileList'] = entity.customerMobileList;
  28 + data['customerQr'] = entity.customerQr;
16 29 return data;
17 30 }
18 31  
19 32 extension AppConfigEntityExtension on AppConfigEntity {
20 33 AppConfigEntity copyWith({
21 34 String? safe,
  35 + List<String>? customerMobileList,
  36 + String? customerQr,
22 37 }) {
23 38 return AppConfigEntity()
24   - ..safe = safe ?? this.safe;
  39 + ..safe = safe ?? this.safe
  40 + ..customerMobileList = customerMobileList ?? this.customerMobileList
  41 + ..customerQr = customerQr ?? this.customerQr;
25 42 }
26 43 }
27 44 \ No newline at end of file
... ...
lib/models/app_config_entity.dart
... ... @@ -6,24 +6,29 @@ import &#39;../generated/json/app_config_entity.g.dart&#39;;
6 6  
7 7 @JsonSerializable()
8 8 class AppConfigEntity {
  9 + // 当前是否安全,safe-安全 otherwise-隐藏pay
  10 + String? safe;
9 11  
10   - // 当前是否安全,safe-安全 otherwise-隐藏pay
11   - String? safe;
  12 + // 客服手机号列表
  13 + List<String> customerMobileList = [];
12 14  
  15 + // 客服二维码图片地址
  16 + String? customerQr;
13 17  
14   - AppConfigEntity();
  18 + AppConfigEntity();
15 19  
16   - factory AppConfigEntity.fromJson(Map<String, dynamic> json) => $AppConfigEntityFromJson(json);
  20 + factory AppConfigEntity.fromJson(Map<String, dynamic> json) =>
  21 + $AppConfigEntityFromJson(json);
17 22  
18   - Map<String, dynamic> toJson() => $AppConfigEntityToJson(this);
  23 + Map<String, dynamic> toJson() => $AppConfigEntityToJson(this);
19 24  
20   - @override
21   - String toString() {
22   - return jsonEncode(this);
23   - }
  25 + @override
  26 + String toString() {
  27 + return jsonEncode(this);
  28 + }
24 29  
25   - // 是否审核中(null或者非"safe"即不安全)
26   - bool isAppReviewing() {
27   - return safe != "safe";
28   - }
  30 + // 是否审核中(null或者非"safe"即不安全)
  31 + bool isAppReviewing() {
  32 + return safe != "safe";
  33 + }
29 34 }
30 35 \ No newline at end of file
... ...
lib/pages/home/widgets/BaseHomeHeaderWidget.dart
... ... @@ -10,6 +10,7 @@ import &#39;../../../models/course_entity.dart&#39;;
10 10 import '../../../route/route.dart';
11 11 import '../../../utils/image_util.dart';
12 12 import '../../user/bloc/user_bloc.dart';
  13 +import 'customer_service_qr_dialog.dart';
13 14  
14 15 typedef HeaderCallback = void Function(dynamic);
15 16  
... ... @@ -80,6 +81,28 @@ class BaseHomeHeaderWidget extends StatelessWidget {
80 81 textAlign: TextAlign.left,
81 82 style: TextStyle(color: Colors.white, fontSize: 30.0),
82 83 )),
  84 + FutureBuilder(
  85 + future: AppConfigHelper.getAppConfig(),
  86 + builder: (context, snapshot) {
  87 + final customerQr = snapshot.data?.customerQr?.trim() ?? '';
  88 + if (customerQr.isEmpty) {
  89 + return const SizedBox.shrink();
  90 + }
  91 + return GestureDetector(
  92 + onTap: () =>
  93 + showCustomerServiceQrDialog(context, customerQr),
  94 + child: Padding(
  95 + padding: EdgeInsets.only(right: 12.w),
  96 + child: Image.asset(
  97 + 'ic_customer_service'.assetPng,
  98 + width: 90.w,
  99 + height: 28.h,
  100 + fit: BoxFit.contain,
  101 + ),
  102 + ),
  103 + );
  104 + },
  105 + ),
83 106 Offstage(
84 107 offstage: AppConfigHelper.shouldHidePay() ||
85 108 !UserUtil.isLogined(),
... ...
lib/pages/home/widgets/customer_service_qr_dialog.dart 0 → 100644
  1 +import 'package:flutter/material.dart';
  2 +import 'package:flutter_screenutil/flutter_screenutil.dart';
  3 +
  4 +Future<void> showCustomerServiceQrDialog(
  5 + BuildContext context,
  6 + String imageUrl,
  7 +) {
  8 + return showDialog<void>(
  9 + context: context,
  10 + barrierColor: Colors.black54,
  11 + barrierDismissible: true,
  12 + builder: (context) => Dialog(
  13 + backgroundColor: Colors.transparent,
  14 + insetPadding: EdgeInsets.symmetric(horizontal: 24.w, vertical: 10.h),
  15 + child: ClipRRect(
  16 + borderRadius: BorderRadius.circular(12.r),
  17 + child: SizedBox(
  18 + height: 340.h,
  19 + child: AspectRatio(
  20 + aspectRatio: 870 / 1110,
  21 + child: Image.network(
  22 + imageUrl,
  23 + width: double.infinity,
  24 + height: double.infinity,
  25 + fit: BoxFit.cover,
  26 + loadingBuilder: (context, child, progress) {
  27 + if (progress == null) {
  28 + return child;
  29 + }
  30 + return Container(
  31 + color: Colors.white,
  32 + alignment: Alignment.center,
  33 + child: const CircularProgressIndicator(),
  34 + );
  35 + },
  36 + errorBuilder: (context, error, stackTrace) => Container(
  37 + color: Colors.white,
  38 + alignment: Alignment.center,
  39 + child: Text(
  40 + '图片加载失败',
  41 + style: TextStyle(
  42 + color: Colors.grey[600],
  43 + fontSize: 14.sp,
  44 + ),
  45 + ),
  46 + ),
  47 + ),
  48 + ),
  49 + ),
  50 + ),
  51 + ),
  52 + );
  53 +}
... ...
lib/pages/shop/home/shop_home_page.dart
... ... @@ -5,7 +5,6 @@ import &#39;package:wow_english/common/extension/string_extension.dart&#39;;
5 5 import 'package:wow_english/common/widgets/we_app_bar.dart';
6 6 import 'package:wow_english/pages/shop/home/widgets/product_item.dart';
7 7 import 'package:wow_english/route/route.dart';
8   -import 'package:wow_english/utils/toast_util.dart';
9 8  
10 9 import 'bloc/shop_home_bloc.dart';
11 10  
... ...
lib/pages/shop/home/widgets/product_item.dart
... ... @@ -67,10 +67,13 @@ class ProductItem extends StatelessWidget {
67 67 ),
68 68 ),
69 69 GestureDetector(
  70 + behavior: HitTestBehavior.opaque,
70 71 onTap: () {
71 72 onTap();
72 73 },
73 74 child: Container(
  75 + width: double.infinity,
  76 + constraints: BoxConstraints(minHeight: 28.h),
74 77 decoration: BoxDecoration(
75 78 color: const Color(0xFFF5C51F),
76 79 borderRadius: BorderRadius.circular(5.r),
... ... @@ -78,17 +81,14 @@ class ProductItem extends StatelessWidget {
78 81 color: const Color(0xFF333333),
79 82 width: 1.0,
80 83 )),
81   - padding: EdgeInsets.symmetric(
82   - vertical: 1.h
83   - ),
  84 + padding: EdgeInsets.symmetric(vertical: 4.h),
84 85 child: Center(
85 86 child: Text(
86 87 '立即购买',
87 88 style: TextStyle(
88 89 fontSize: 10.sp, color: const Color(0xFF333333)),
89 90 ),
90   - )
91   - ),
  91 + )),
92 92 )
93 93 ],
94 94 ),
... ...
lib/pages/user/user_page.dart
... ... @@ -13,6 +13,7 @@ import &#39;package:wow_english/models/user_entity.dart&#39;;
13 13 import 'package:wow_english/pages/user/bloc/user_bloc.dart';
14 14 import 'package:wow_english/route/route.dart';
15 15 import 'package:wow_english/utils/image_util.dart';
  16 +import 'package:wow_english/utils/toast_util.dart';
16 17  
17 18 class UserPage extends StatelessWidget {
18 19 const UserPage({super.key});
... ... @@ -196,6 +197,16 @@ class _UserView extends StatelessWidget {
196 197 child: 12.verticalSpace,
197 198 ),
198 199 OutlinedButton(
  200 + onPressed: () {
  201 + _showCustomerServiceDialog(context);
  202 + },
  203 + style: normalButtonStyle,
  204 + child: Text(
  205 + "联系客服",
  206 + style: textStyle21sp,
  207 + )),
  208 + 12.verticalSpace,
  209 + OutlinedButton(
199 210 onPressed: () => pushNamed(AppRouteName.fogPwd),
200 211 style: normalButtonStyle,
201 212 child: Text(
... ... @@ -232,16 +243,6 @@ class _UserView extends StatelessWidget {
232 243 12.verticalSpace,
233 244 OutlinedButton(
234 245 onPressed: () {
235   - _showTeacherSelectionDialog(context);
236   - },
237   - style: normalButtonStyle,
238   - child: Text(
239   - "联系客服",
240   - style: textStyle21sp,
241   - )),
242   - 12.verticalSpace,
243   - OutlinedButton(
244   - onPressed: () {
245 246 pushNamed(AppRouteName.setting);
246 247 },
247 248 style: normalButtonStyle,
... ... @@ -308,7 +309,33 @@ class _UserView extends StatelessWidget {
308 309 },
309 310 );
310 311  
311   - void _showTeacherSelectionDialog(BuildContext context) {
  312 + Future<void> _showCustomerServiceDialog(BuildContext context) async {
  313 + try {
  314 + final config = await AppConfigHelper.getAppConfig();
  315 + if (!context.mounted) {
  316 + return;
  317 + }
  318 +
  319 + final mobileList = config?.customerMobileList
  320 + .map((mobile) => mobile.trim())
  321 + .where((mobile) => mobile.isNotEmpty)
  322 + .toSet()
  323 + .toList() ??
  324 + [];
  325 + if (mobileList.isEmpty) {
  326 + showToast('暂无客服联系方式');
  327 + return;
  328 + }
  329 + _showMobileSelectionDialog(context, mobileList);
  330 + } catch (_) {
  331 + if (context.mounted) {
  332 + showToast('客服联系方式获取失败,请稍后重试');
  333 + }
  334 + }
  335 + }
  336 +
  337 + void _showMobileSelectionDialog(
  338 + BuildContext context, List<String> mobileList) {
312 339 showModalBottomSheet(
313 340 context: context,
314 341 backgroundColor: Colors.white,
... ... @@ -333,30 +360,28 @@ class _UserView extends StatelessWidget {
333 360 borderRadius: BorderRadius.circular(2),
334 361 ),
335 362 ),
336   - // 老师选项列表
  363 + // 客服手机号列表
337 364 Flexible(
338 365 child: SingleChildScrollView(
339 366 child: Column(
340 367 mainAxisSize: MainAxisSize.min,
341   - children: [
342   - // 恐龙老师选项
343   - ListTile(
344   - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  368 + children:
  369 + List.generate(mobileList.length * 2 - 1, (index) {
  370 + if (index.isOdd) {
  371 + return const Divider(height: 1);
  372 + }
  373 + final mobile = mobileList[index ~/ 2];
  374 + return ListTile(
  375 + contentPadding: const EdgeInsets.symmetric(
  376 + horizontal: 8, vertical: 4),
345 377 title: Text(
346   - '恐龙老师',
  378 + mobile,
347 379 style: TextStyle(
348 380 fontSize: 14.sp,
349 381 fontWeight: FontWeight.w600,
350 382 color: Colors.black87,
351 383 ),
352 384 ),
353   - subtitle: Text(
354   - '19357119913',
355   - style: TextStyle(
356   - fontSize: 12.sp,
357   - color: Colors.grey[600],
358   - ),
359   - ),
360 385 trailing: Icon(
361 386 Icons.phone,
362 387 color: Colors.green[600],
... ... @@ -364,39 +389,10 @@ class _UserView extends StatelessWidget {
364 389 ),
365 390 onTap: () {
366 391 Navigator.pop(context);
367   - _launchPhone('tel:+8619357119913');
  392 + _launchPhone(mobile);
368 393 },
369   - ),
370   - const Divider(height: 1),
371   - // Rose老师选项
372   - ListTile(
373   - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
374   - title: Text(
375   - 'Rose老师',
376   - style: TextStyle(
377   - fontSize: 14.sp,
378   - fontWeight: FontWeight.w600,
379   - color: Colors.black87,
380   - ),
381   - ),
382   - subtitle: Text(
383   - '19033986279',
384   - style: TextStyle(
385   - fontSize: 12.sp,
386   - color: Colors.grey[600],
387   - ),
388   - ),
389   - trailing: Icon(
390   - Icons.phone,
391   - color: Colors.green[600],
392   - size: 18,
393   - ),
394   - onTap: () {
395   - Navigator.pop(context);
396   - _launchPhone('tel:+8619033986279');
397   - },
398   - ),
399   - ],
  394 + );
  395 + }),
400 396 ),
401 397 ),
402 398 ),
... ... @@ -433,8 +429,9 @@ class _UserView extends StatelessWidget {
433 429 }
434 430  
435 431 void _launchPhone(String phone) async {
436   - if (await canLaunchUrl(Uri.parse(phone))) {
437   - await launchUrl(Uri.parse(phone));
  432 + final phoneUri = Uri(scheme: 'tel', path: phone);
  433 + if (await canLaunchUrl(phoneUri)) {
  434 + await launchUrl(phoneUri);
438 435 } else {
439 436 throw 'Could not phone $phone';
440 437 }
... ...
pubspec.yaml
... ... @@ -16,7 +16,7 @@ publish_to: &#39;none&#39; # Remove this line if you wish to publish to pub.dev
16 16 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17 17 # In Windows, build-name is used as the major, minor, and patch parts
18 18 # of the product and file versions while build-number is used as the build suffix.
19   -version: 1.0.11+11
  19 +version: 1.0.13+13
20 20  
21 21 environment:
22 22 sdk: '>=3.2.0 <4.0.0'
... ...