Skip to content

Commit d0b3b43

Browse files
committed
FEAT: 按渠道处理应用更新与订阅诊断
1 parent 0925fe7 commit d0b3b43

25 files changed

Lines changed: 919 additions & 55 deletions

TASKS.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,64 @@
11
# Echo Loop 任务清单
22

3-
> 最后更新:2026-07-06(修复 Release iOS RevenueCat Apple key 变量来源
3+
> 最后更新:2026-07-06(订阅页自动续费说明弱化
44
> 当前焦点:Android 结束录音闪退(离线 ASR / Silero VAD)——**仍未解决**
55
6+
## 已完成:订阅页自动续费说明弱化
7+
8+
用户反馈订阅页「到期前 24 小时」提醒语气偏重且信息冗余,后续又反馈说明文字视觉仍太黑。保留自动续费与账户管理/取消的核心披露,去掉 24 小时细节,并降低辅助说明文字对比度,让它退到 CTA 下方的次要信息层级。
9+
10+
- [x] **中文文案收敛**`premiumAutoRenewNotice` 改为「自动续费,可在 App Store 账户中管理或取消。」。
11+
- [x] **英文文案同步**:英文改为 `Auto-renews. Manage or cancel in your App Store account.`
12+
- [x] **视觉层级弱化**:自动续费说明使用更低透明度的 `onSurfaceVariant`,浅色模式更淡,深色模式保留可读性。
13+
- [x] **本地化同步**:更新 ARB 与生成的 `app_localizations*.dart` 文件,避免运行时文案分叉。
14+
- [x] **验证**`flutter analyze lib/features/subscription/screens/paywall_screen.dart lib/l10n/app_localizations.dart lib/l10n/app_localizations_en.dart lib/l10n/app_localizations_zh.dart test/features/subscription/paywall_screen_test.dart` 0 问题;`flutter test test/features/subscription/paywall_screen_test.dart` 全过。
15+
16+
**完成时间**: 2026-07-06
17+
18+
## 已完成:订阅页条款链接改为随内容滚动
19+
20+
用户反馈会员页底部「服务条款 / 隐私政策」固定在安全区底部,视觉上挤压甚至遮挡订阅按钮。订阅页应按购买信息阅读顺序,把法律链接放在 CTA 与自动续费说明之后,并随页面一起滚动。
21+
22+
- [x] **布局收敛**`PaywallScreen` 去掉固定底部 footer,改为单一 `ListView` 承载权益、套餐、CTA、自动续费说明与法律链接。
23+
- [x] **法律链接位置**`_LegalFooter` 移到购买区域末尾,仅购买态展示;会员管理态继续不展示。
24+
- [x] **回归测试**:补充 paywall widget 断言,锁定 `Terms of Service` 属于 `ListView` 滚动内容,并确认会员态不显示条款/隐私链接。
25+
- [x] **验证**`flutter analyze lib/features/subscription/screens/paywall_screen.dart test/features/subscription/paywall_screen_test.dart` 0 问题;`flutter test test/features/subscription/paywall_screen_test.dart` 全过。
26+
27+
**完成时间**: 2026-07-06
28+
29+
## 已完成:订阅价格链路诊断日志
30+
31+
用户反馈会员页 UI 显示人民币价格,但 Apple 付款弹窗显示美区账号价格。先补齐 RevenueCat / StoreKit 价格来源日志,用于确认 `getOfferings()` 的 package price、当前 storefront、direct `getProducts()` 价格与购买前价格是否一致。
32+
33+
- [x] **storefront 记录**`RevenueCatPurchaseService.fetchPlans()` 与购买前分别记录 `Purchases.storefront.countryCode`
34+
- [x] **Offering 价格记录**:套餐列表拉取时记录 current offering 的 package id、package type、product id 与 `storeProduct.priceString`
35+
- [x] **Direct product 价格记录**:套餐列表拉取与购买前额外调用 `Purchases.getProducts(productIds)`,记录 direct product 的 `priceString``currencyCode` 与 raw price;查询失败只写日志,不影响现有流程。
36+
- [x] **验证**`dart format lib/features/subscription/services/revenuecat_purchase_service.dart``flutter analyze lib/features/subscription/services/revenuecat_purchase_service.dart` 0 问题。
37+
38+
**完成时间**: 2026-07-06
39+
40+
## 已完成:更新模块渠道化改造
41+
42+
用户反馈现有更新模块存在三类风险:iOS 无法强制更新;Google Play 用户依赖后端 `version.json` 可能出现“提示更新但 Play 未上架”;Android 无法区分 Google Play 与官网/GitHub APK 安装,导致中国用户被错误导向 Google Play,甚至诱导卸载重装丢数据。
43+
44+
- [x] **远端配置向前兼容升级**`../fluency-frontend/apps/app/public/version.json` 保留旧顶层字段,新增 `schemaVersion=2``platforms.ios / platforms.android.googlePlay / platforms.android.apk` 渠道配置;旧客户端继续读顶层字段,新客户端优先读渠道字段。
45+
- [x] **iOS 强制更新补齐**:iOS 仍用 App Store Lookup 获取真实可下载 `latestVersion` 和商店链接,同时从 `version.json` 读取 iOS `minimumVersion`;若最低版本高于 App Store 当前版本则不强制,避免审核中版本锁死用户。
46+
- [x] **Android 渠道分流**:新增 `top.echo-loop/app_update` MethodChannel,Android 原生层只读取安装来源;Google Play 安装包主按钮打开 `market://details?id=app.echoloop`,失败回退 HTTPS 商店页;非 Play/未知来源走 APK 直链。
47+
- [x] **更新弹窗兜底**:APK 渠道主按钮直接下载 APK;删除 Play Core / In-App Update 逻辑,避免柔性更新完成链路、恢复状态等特殊分支。
48+
- [x] **验证**`flutter analyze` 更新模块相关文件 0 问题;`flutter test test/models/app_update_info_test.dart test/services/app_update_checker_test.dart test/services/app_update_launcher_test.dart test/widgets/app_update_dialog_test.dart test/providers/app_update_provider_test.dart` 全过;`./gradlew :app:compileProdDebugKotlin -x compileFlutterBuildProdDebug` 构建成功。
49+
50+
**完成时间**: 2026-07-06
51+
52+
### 收尾审查 + 加固(2026-07-06)
53+
54+
对上述改造做可靠性审查,收敛 3 处问题:
55+
56+
- [x] **`minimumVersion` 全平台只认 `platforms.*`**:iOS/Android GP/Android APK 三处去掉 `?? manifest.minimumVersion` 兜底,缺省回落 `'0.0.0'`。顶层 `minimumVersion` 仅供不识别 `platforms` schema 的**旧版本 App**兼容,新版本一律忽略——避免为旧版而设的顶层 min 误触发(尤其 iOS 的不可关闭强更)。`latestVersion` 不变(GP 无 per-channel latest,soft update 无阻断风险)。
57+
- [x] **Google Play 隐藏「直接下载 APK」逃生按钮**:贴合「统一走商店更新」并规避 Play 政策风险。移除 `secondaryDownloadUrl` 后该机制无路径再填充,一并清理死代码(model 字段、dialog 按钮/方法、传参、l10n `downloadApkDirectly`、对应测试)。
58+
- [x] **Launcher 回退链补齐**:GP 商店链接打不开时回退改为 `platforms.android.googlePlay.fallbackUrl ?? downloadUrl['fallback']`,使 `_googlePlayDownloadUrl` 写入的默认 Play 网页链接生效。
59+
- [x] **验证**`flutter analyze` 改动文件 0 问题;更新相关 4 个测试文件(含新增「顶层 min 不波及各平台」「GP 无渠道 fallback 回退顶层」用例)全过;`flutter test` 全量 3864 通过。
60+
- ⚠️ **发布前需确认**:生产 `version.json` 已按各平台补齐 `platforms.*.minimumVersion`(否则该平台永不强更),顶层字段保留给旧版。
61+
662
## 已完成:修复 Release iOS RevenueCat Apple key 变量来源
763

864
最新 GitHub Actions `Release` run `28766561568` 中 Android release job 已成功完成 APK/AAB 构建与上传;iOS release job 失败在 `Build iOS app (no codesign)`,日志显示 `REVENUECAT_API_KEY_APPLE` 为空并触发硬校验退出。根因是 Release workflow 的 iOS 步骤读取 `${{ secrets.REVENUECAT_API_KEY_APPLE }}`,而 RevenueCat public SDK API key 按项目约定配置在 GitHub repository variables,Android 步骤也已使用 `${{ vars.REVENUECAT_API_KEY_GOOGLE }}`

android/app/src/main/kotlin/app/echoloop/MainActivity.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package app.echoloop
22

3+
import android.os.Build
34
import com.google.android.gms.common.ConnectionResult
45
import com.google.android.gms.common.GoogleApiAvailability
56
import android.util.Log
@@ -9,6 +10,7 @@ import io.flutter.plugin.common.MethodChannel
910

1011
class MainActivity : AudioServiceActivity() {
1112
private var googleServicesChannel: MethodChannel? = null
13+
private var appUpdateChannel: MethodChannel? = null
1214
private var speechPracticeHandler: AndroidSpeechPracticeHandler? = null
1315
private var audioDecodeHandler: AndroidAudioDecodeHandler? = null
1416

@@ -26,6 +28,17 @@ class MainActivity : AudioServiceActivity() {
2628
}
2729
}
2830
}
31+
appUpdateChannel = MethodChannel(
32+
flutterEngine.dartExecutor.binaryMessenger,
33+
"top.echo-loop/app_update",
34+
).also { channel ->
35+
channel.setMethodCallHandler { call, result ->
36+
when (call.method) {
37+
"getInstallerPackageName" -> result.success(installerPackageName())
38+
else -> result.notImplemented()
39+
}
40+
}
41+
}
2942
speechPracticeHandler = AndroidSpeechPracticeHandler(
3043
this, flutterEngine.dartExecutor.binaryMessenger,
3144
)
@@ -37,6 +50,8 @@ class MainActivity : AudioServiceActivity() {
3750
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
3851
googleServicesChannel?.setMethodCallHandler(null)
3952
googleServicesChannel = null
53+
appUpdateChannel?.setMethodCallHandler(null)
54+
appUpdateChannel = null
4055
speechPracticeHandler?.dispose()
4156
speechPracticeHandler = null
4257
audioDecodeHandler?.dispose()
@@ -68,4 +83,20 @@ class MainActivity : AudioServiceActivity() {
6883
else -> "UNKNOWN"
6984
}
7085
}
86+
87+
private fun installerPackageName(): String? {
88+
return try {
89+
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
90+
packageManager.getInstallSourceInfo(packageName).installingPackageName
91+
} else {
92+
@Suppress("DEPRECATION")
93+
packageManager.getInstallerPackageName(packageName)
94+
}
95+
Log.i("AppUpdate", "installerPackageName=${installer ?: "(null)"}")
96+
installer
97+
} catch (e: Exception) {
98+
Log.w("AppUpdate", "installer source failed", e)
99+
null
100+
}
101+
}
71102
}

ios/Podfile.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ PODS:
186186
- GTMSessionFetcher/Core (3.5.0)
187187
- GTMSessionFetcher/Full (3.5.0):
188188
- GTMSessionFetcher/Core
189+
- in_app_purchase_storekit (0.0.1):
190+
- Flutter
191+
- FlutterMacOS
189192
- integration_test (0.0.1):
190193
- Flutter
191194
- just_audio (0.0.1):
@@ -289,6 +292,7 @@ DEPENDENCIES:
289292
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
290293
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
291294
- google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`)
295+
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
292296
- integration_test (from `.symlinks/plugins/integration_test/ios`)
293297
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
294298
- just_waveform (from `.symlinks/plugins/just_waveform/darwin`)
@@ -366,6 +370,8 @@ EXTERNAL SOURCES:
366370
:path: ".symlinks/plugins/flutter_tts/ios"
367371
google_sign_in_ios:
368372
:path: ".symlinks/plugins/google_sign_in_ios/darwin"
373+
in_app_purchase_storekit:
374+
:path: ".symlinks/plugins/in_app_purchase_storekit/darwin"
369375
integration_test:
370376
:path: ".symlinks/plugins/integration_test/ios"
371377
just_audio:
@@ -433,6 +439,7 @@ SPEC CHECKSUMS:
433439
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
434440
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
435441
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
442+
in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8
436443
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
437444
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
438445
just_waveform: be2cb6dec86096cb972bf8b73c893fe16bf1cff4

ios/Runner.xcodeproj/project.pbxproj

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,14 +355,10 @@
355355
inputFileListPaths = (
356356
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
357357
);
358-
inputPaths = (
359-
);
360358
name = "[CP] Embed Pods Frameworks";
361359
outputFileListPaths = (
362360
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
363361
);
364-
outputPaths = (
365-
);
366362
runOnlyForDeploymentPostprocessing = 0;
367363
shellPath = /bin/sh;
368364
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -413,14 +409,10 @@
413409
inputFileListPaths = (
414410
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
415411
);
416-
inputPaths = (
417-
);
418412
name = "[CP] Copy Pods Resources";
419413
outputFileListPaths = (
420414
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
421415
);
422-
outputPaths = (
423-
);
424416
runOnlyForDeploymentPostprocessing = 0;
425417
shellPath = /bin/sh;
426418
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";

lib/features/subscription/screens/paywall_screen.dart

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -81,30 +81,17 @@ class _PaywallScreenState extends ConsumerState<PaywallScreen> {
8181
body: SafeArea(
8282
child: Stack(
8383
children: [
84-
Column(
85-
children: [
86-
Expanded(
87-
child: ListView(
88-
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
89-
children: isPremium
90-
? _buildMemberBody(l10n)
91-
: [
92-
_Header(l10n: l10n),
93-
const SizedBox(height: 24),
94-
_BenefitCard(l10n: l10n),
95-
const SizedBox(height: 24),
96-
_buildPurchaseArea(l10n),
97-
],
98-
),
99-
),
100-
// 条款/隐私固定在页面底部(类登录页);仅在展示购买选项时显示,
101-
// 会员管理态无购买行为,不显示。
102-
if (!isPremium)
103-
Padding(
104-
padding: const EdgeInsets.fromLTRB(20, 4, 20, 8),
105-
child: _LegalFooter(l10n: l10n),
106-
),
107-
],
84+
ListView(
85+
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
86+
children: isPremium
87+
? _buildMemberBody(l10n)
88+
: [
89+
_Header(l10n: l10n),
90+
const SizedBox(height: 24),
91+
_BenefitCard(l10n: l10n),
92+
const SizedBox(height: 24),
93+
_buildPurchaseArea(l10n),
94+
],
10895
),
10996
if (_busy)
11097
const ColoredBox(
@@ -206,10 +193,18 @@ class _PaywallScreenState extends ConsumerState<PaywallScreen> {
206193
Text(
207194
l10n.premiumAutoRenewNotice,
208195
style: Theme.of(context).textTheme.bodySmall?.copyWith(
209-
color: Theme.of(context).colorScheme.onSurfaceVariant,
196+
color: Theme.of(context).colorScheme.onSurfaceVariant
197+
.withValues(
198+
alpha: Theme.of(context).brightness == Brightness.dark
199+
? 0.72
200+
: 0.58,
201+
),
210202
),
211203
textAlign: TextAlign.center,
212204
),
205+
const SizedBox(height: 8),
206+
// 购买相关法律链接跟随订阅按钮一起滚动,避免小屏底部固定区挤压 CTA。
207+
_LegalFooter(l10n: l10n),
213208
],
214209
);
215210
},

lib/features/subscription/services/revenuecat_purchase_service.dart

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class RevenueCatPurchaseService implements PurchaseService {
3434
@override
3535
Future<List<SubscriptionPlan>> fetchPlans() async {
3636
try {
37+
await _logStorefront('fetchPlans:beforeOfferings');
3738
final offerings = await Purchases.getOfferings();
3839
final current = offerings.current;
3940
AppLogger.log(
@@ -59,10 +60,17 @@ class RevenueCatPurchaseService implements PurchaseService {
5960
for (final p in current.availablePackages) {
6061
AppLogger.log(
6162
'Subscription',
62-
'package=${p.identifier} type=${p.packageType} '
63+
'offering package=${p.identifier} type=${p.packageType} '
6364
'product=${p.storeProduct.identifier} price=${p.storeProduct.priceString}',
6465
);
6566
}
67+
await _logDirectProductsForDiagnostics(
68+
current.availablePackages
69+
.map((p) => p.storeProduct.identifier)
70+
.toSet()
71+
.toList(),
72+
stage: 'fetchPlans:directProducts',
73+
);
6674
return current.availablePackages.map(_packageToPlan).toList();
6775
} catch (e) {
6876
AppLogger.log('Subscription', 'getOfferings 异常: $e');
@@ -104,6 +112,10 @@ class RevenueCatPurchaseService implements PurchaseService {
104112
'product=${package.storeProduct.identifier} '
105113
'price=${package.storeProduct.priceString}',
106114
);
115+
await _logStorefront('purchase:beforeSheet');
116+
await _logDirectProductsForDiagnostics([
117+
package.storeProduct.identifier,
118+
], stage: 'purchase:directProductBeforeSheet');
107119
try {
108120
final result = await Purchases.purchase(PurchaseParams.package(package));
109121
AppLogger.log('Subscription', 'RC purchase 完成: 交易成功,开始映射权益');
@@ -210,6 +222,56 @@ class RevenueCatPurchaseService implements PurchaseService {
210222
};
211223
}
212224

225+
/// 记录当前 Apple/Google 商店账号所在 storefront。
226+
///
227+
/// 该值决定平台本地化价格。诊断时用它对齐 Offering 商品价、direct product
228+
/// 查询价与系统付款弹窗价格,判断是否存在 SDK/商店商品详情缓存不一致。
229+
Future<void> _logStorefront(String stage) async {
230+
try {
231+
final storefront = await Purchases.storefront;
232+
AppLogger.log(
233+
'Subscription',
234+
'storefront[$stage]=${storefront?.countryCode ?? "null"}',
235+
);
236+
} catch (e) {
237+
AppLogger.log('Subscription', 'storefront[$stage] 获取失败: $e');
238+
}
239+
}
240+
241+
/// 直接向 RevenueCat/商店查询商品详情并记录价格。
242+
///
243+
/// Offering 的 package 内也带 [StoreProduct],但本问题需要确认它是否与
244+
/// 当前 storefront 下的 direct product 查询结果一致。这里只做诊断日志,
245+
/// 不参与业务决策,避免改变现有购买路径。
246+
Future<void> _logDirectProductsForDiagnostics(
247+
List<String> productIds, {
248+
required String stage,
249+
}) async {
250+
if (productIds.isEmpty) return;
251+
try {
252+
final products = await Purchases.getProducts(productIds);
253+
final foundIds = <String>{};
254+
for (final product in products) {
255+
foundIds.add(product.identifier);
256+
AppLogger.log(
257+
'Subscription',
258+
'direct product[$stage] product=${product.identifier} '
259+
'price=${product.priceString} currency=${product.currencyCode} '
260+
'rawPrice=${product.price}',
261+
);
262+
}
263+
final missingIds = productIds.where((id) => !foundIds.contains(id));
264+
if (missingIds.isNotEmpty) {
265+
AppLogger.log(
266+
'Subscription',
267+
'direct product[$stage] 未返回商品: ${missingIds.toList()}',
268+
);
269+
}
270+
} catch (e) {
271+
AppLogger.log('Subscription', 'direct product[$stage] 查询失败: $e');
272+
}
273+
}
274+
213275
Entitlement _entitlementFrom(CustomerInfo info) {
214276
final active = info.entitlements.active;
215277
// 诊断:打印 RevenueCat 实际返回的 active 权益键 + 全部权益键 + 我们要找的标识。

lib/l10n/app_en.arb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
}
4646
},
4747
"premiumLifetimeAccessNote": "Lifetime access, no renewal needed",
48-
"premiumAutoRenewNotice": "Subscriptions auto-renew unless canceled at least 24 hours before the end of the current period. You can manage or cancel anytime in your store account.",
48+
"premiumAutoRenewNotice": "Auto-renews. Manage or cancel in your App Store account.",
4949
"premiumNoPlans": "No plans available right now. Please try again later.",
5050
"premiumLoginRequired": "Please sign in before subscribing.",
5151
"premiumPurchaseFailed": "Purchase failed. Please try again.",

0 commit comments

Comments
 (0)