feat: 添加APK在线升级和遥测统计模块
APK升级模块 (lib/core/updater/): - 支持自建服务器和Google Play双渠道更新 - 版本检测、APK下载、SHA-256校验、安装 - 应用市场来源检测 - 强制更新和普通更新对话框 遥测模块 (lib/core/telemetry/): - 设备信息采集 (品牌、型号、系统版本、屏幕等) - 会话管理 (DAU日活统计) - 心跳服务 (实时在线人数统计) - 事件队列和批量上传 - 远程配置热更新 Android原生配置: - MainActivity.kt Platform Channel实现 - FileProvider配置 (APK安装) - 权限配置 (INTERNET, REQUEST_INSTALL_PACKAGES) 文档: - docs/backend_api_guide.md 后端API开发指南 - docs/testing_guide.md 测试指南 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
429173464c
commit
be4ef7d1aa
|
|
@ -1,4 +1,9 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- 网络权限 -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<!-- APK安装权限 (Android 8.0+) -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="榴莲皇后"
|
android:label="榴莲皇后"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
@ -30,6 +35,17 @@
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|
||||||
|
<!-- FileProvider for APK installation (Android 7.0+) -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,85 @@
|
||||||
package com.rwadurian.rwa_android_app
|
package com.rwadurian.rwa_android_app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
class MainActivity : FlutterActivity()
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val INSTALLER_CHANNEL = "com.rwadurian.app/apk_installer"
|
||||||
|
private val MARKET_CHANNEL = "com.rwadurian.app/app_market"
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
|
||||||
|
// APK 安装器通道
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, INSTALLER_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"installApk" -> {
|
||||||
|
val apkPath = call.argument<String>("apkPath")
|
||||||
|
if (apkPath != null) {
|
||||||
|
try {
|
||||||
|
installApk(apkPath)
|
||||||
|
result.success(true)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("INSTALL_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PATH", "APK path is null", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用市场检测通道
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, MARKET_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"getInstallerPackageName" -> {
|
||||||
|
try {
|
||||||
|
val installer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
packageManager.getInstallSourceInfo(packageName).installingPackageName
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
packageManager.getInstallerPackageName(packageName)
|
||||||
|
}
|
||||||
|
result.success(installer)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installApk(apkPath: String) {
|
||||||
|
val apkFile = File(apkPath)
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
throw Exception("APK file not found: $apkPath")
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
|
||||||
|
val apkUri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
this,
|
||||||
|
"${applicationContext.packageName}.fileprovider",
|
||||||
|
apkFile
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Uri.fromFile(apkFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<!-- 应用内部文件目录 -->
|
||||||
|
<files-path name="internal_files" path="." />
|
||||||
|
<!-- 应用缓存目录 -->
|
||||||
|
<cache-path name="cache" path="." />
|
||||||
|
<!-- 应用外部文件目录 -->
|
||||||
|
<external-files-path name="external_files" path="." />
|
||||||
|
</paths>
|
||||||
|
|
@ -0,0 +1,725 @@
|
||||||
|
# 后端 API 开发指南
|
||||||
|
|
||||||
|
> 本文档为 Flutter 前端已实现功能提供后端 API 接口规范,供后端开发参考。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
1. [APK 在线升级 API](#1-apk-在线升级-api)
|
||||||
|
2. [遥测系统 API](#2-遥测系统-api)
|
||||||
|
3. [数据库设计](#3-数据库设计)
|
||||||
|
4. [Redis 数据结构](#4-redis-数据结构)
|
||||||
|
5. [定时任务](#5-定时任务)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. APK 在线升级 API
|
||||||
|
|
||||||
|
### 1.1 版本检测接口
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/app/version/check
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| platform | string | 是 | 平台类型: `android` / `ios` |
|
||||||
|
| currentVersion | string | 是 | 当前版本号,如 `1.0.0` |
|
||||||
|
| currentVersionCode | int | 是 | 当前版本代码,如 `1` |
|
||||||
|
|
||||||
|
**响应示例 - 有新版本**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"hasUpdate": true,
|
||||||
|
"version": "1.1.0",
|
||||||
|
"versionCode": 2,
|
||||||
|
"downloadUrl": "https://cdn.rwadurian.com/releases/app-v1.1.0.apk",
|
||||||
|
"fileSize": 52428800,
|
||||||
|
"fileSizeFriendly": "50.0 MB",
|
||||||
|
"sha256": "a1b2c3d4e5f6...完整64字符哈希",
|
||||||
|
"forceUpdate": false,
|
||||||
|
"updateLog": "1. 新增挖矿动画效果\n2. 修复已知BUG\n3. 性能优化",
|
||||||
|
"releaseDate": "2024-01-15T10:00:00Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例 - 已是最新版本**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"hasUpdate": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端对应代码**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/updater/version_checker.dart
|
||||||
|
Future<VersionInfo?> checkForUpdate() async {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/app/version/check',
|
||||||
|
queryParameters: {
|
||||||
|
'platform': 'android',
|
||||||
|
'currentVersion': packageInfo.version,
|
||||||
|
'currentVersionCode': int.parse(packageInfo.buildNumber),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 APK 下载
|
||||||
|
|
||||||
|
**要求**
|
||||||
|
|
||||||
|
- 下载链接必须使用 HTTPS
|
||||||
|
- 支持断点续传 (Range 请求头)
|
||||||
|
- 提供正确的 `Content-Length` 响应头
|
||||||
|
- 提供 SHA-256 校验值供前端验证文件完整性
|
||||||
|
|
||||||
|
**前端下载流程**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 请求版本检测接口获取下载信息
|
||||||
|
2. 使用 Dio 下载 APK 到应用私有目录
|
||||||
|
3. 计算下载文件 SHA-256 与服务器返回值比对
|
||||||
|
4. 校验通过后调用系统安装器安装
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 版本管理后台 (建议)
|
||||||
|
|
||||||
|
建议实现后台管理界面支持:
|
||||||
|
|
||||||
|
- 上传新版本 APK
|
||||||
|
- 自动计算文件大小和 SHA-256
|
||||||
|
- 设置强制更新标志
|
||||||
|
- 填写更新日志
|
||||||
|
- 查看各版本下载统计
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 遥测系统 API
|
||||||
|
|
||||||
|
### 2.1 事件上报接口
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/analytics/events
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <token> // 可选,用户已登录时携带
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"eventId": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"name": "page_view",
|
||||||
|
"type": "page_view",
|
||||||
|
"level": "info",
|
||||||
|
"installId": "device-unique-install-id",
|
||||||
|
"deviceContextId": "ctx-abc123",
|
||||||
|
"sessionId": "session-xyz789",
|
||||||
|
"userId": "user-123",
|
||||||
|
"timestamp": "2024-01-15T10:30:00.000Z",
|
||||||
|
"properties": {
|
||||||
|
"page": "/ranking",
|
||||||
|
"referrer": "/splash"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"eventId": "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
"name": "button_click",
|
||||||
|
"type": "user_action",
|
||||||
|
"level": "info",
|
||||||
|
"installId": "device-unique-install-id",
|
||||||
|
"deviceContextId": "ctx-abc123",
|
||||||
|
"sessionId": "session-xyz789",
|
||||||
|
"userId": "user-123",
|
||||||
|
"timestamp": "2024-01-15T10:30:05.000Z",
|
||||||
|
"properties": {
|
||||||
|
"button": "start_mining",
|
||||||
|
"page": "/mining"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**事件类型枚举 (EventType)**
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|---|------|
|
||||||
|
| `page_view` | 页面浏览 |
|
||||||
|
| `user_action` | 用户操作 |
|
||||||
|
| `api_call` | API 调用 |
|
||||||
|
| `performance` | 性能指标 |
|
||||||
|
| `error` | 错误 |
|
||||||
|
| `crash` | 崩溃 |
|
||||||
|
| `session` | 会话事件 |
|
||||||
|
| `presence` | 在线状态 |
|
||||||
|
|
||||||
|
**事件级别枚举 (EventLevel)**
|
||||||
|
|
||||||
|
| 值 | 说明 |
|
||||||
|
|---|------|
|
||||||
|
| `debug` | 调试 |
|
||||||
|
| `info` | 信息 |
|
||||||
|
| `warning` | 警告 |
|
||||||
|
| `error` | 错误 |
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端对应代码**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/telemetry/uploader/telemetry_uploader.dart
|
||||||
|
Future<bool> uploadBatch({int batchSize = 20}) async {
|
||||||
|
final events = storage.dequeueEvents(batchSize);
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/v1/analytics/events',
|
||||||
|
data: {
|
||||||
|
'events': events.map((e) => e.toJson()).toList(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 心跳接口 (在线状态)
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/presence/heartbeat
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
Content-Type: application/json
|
||||||
|
Authorization: Bearer <token> // 可选
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"installId": "device-unique-install-id",
|
||||||
|
"sessionId": "session-xyz789",
|
||||||
|
"userId": "user-123",
|
||||||
|
"timestamp": "2024-01-15T10:30:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端行为**
|
||||||
|
|
||||||
|
- 应用在前台时,每 **60 秒** 发送一次心跳
|
||||||
|
- 应用进入后台时暂停心跳
|
||||||
|
- 应用恢复前台时立即发送心跳并恢复定时器
|
||||||
|
|
||||||
|
**前端对应代码**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/telemetry/presence/heartbeat_service.dart
|
||||||
|
void _sendHeartbeat() {
|
||||||
|
_dio.post(
|
||||||
|
'/api/v1/presence/heartbeat',
|
||||||
|
data: {
|
||||||
|
'installId': _installId,
|
||||||
|
'sessionId': _sessionId,
|
||||||
|
'userId': _userId,
|
||||||
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 遥测配置接口
|
||||||
|
|
||||||
|
**请求**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/telemetry/config
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"enabled": true,
|
||||||
|
"samplingRate": 1.0,
|
||||||
|
"enabledEventTypes": ["page_view", "user_action", "error", "crash", "session", "presence"],
|
||||||
|
"maxQueueSize": 1000,
|
||||||
|
"uploadBatchSize": 20,
|
||||||
|
"uploadIntervalSeconds": 30,
|
||||||
|
"heartbeatIntervalSeconds": 60,
|
||||||
|
"sessionTimeoutMinutes": 30
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| enabled | bool | 全局开关,false 时前端停止所有遥测 |
|
||||||
|
| samplingRate | double | 采样率 0.0~1.0,用于控制上报比例 |
|
||||||
|
| enabledEventTypes | string[] | 启用的事件类型列表 |
|
||||||
|
| maxQueueSize | int | 本地队列最大容量 |
|
||||||
|
| uploadBatchSize | int | 每批上传事件数量 |
|
||||||
|
| uploadIntervalSeconds | int | 上传间隔秒数 |
|
||||||
|
| heartbeatIntervalSeconds | int | 心跳间隔秒数 |
|
||||||
|
| sessionTimeoutMinutes | int | 会话超时分钟数 |
|
||||||
|
|
||||||
|
**前端对应代码**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// lib/core/telemetry/models/telemetry_config.dart
|
||||||
|
class TelemetryConfig {
|
||||||
|
final bool enabled;
|
||||||
|
final double samplingRate;
|
||||||
|
final List<String> enabledEventTypes;
|
||||||
|
final int maxQueueSize;
|
||||||
|
final int uploadBatchSize;
|
||||||
|
final int uploadIntervalSeconds;
|
||||||
|
final int heartbeatIntervalSeconds;
|
||||||
|
final int sessionTimeoutMinutes;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据库设计
|
||||||
|
|
||||||
|
### 3.1 应用版本表 (app_versions)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE app_versions (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
platform ENUM('android', 'ios') NOT NULL,
|
||||||
|
version VARCHAR(20) NOT NULL COMMENT '版本号 如 1.0.0',
|
||||||
|
version_code INT NOT NULL COMMENT '版本代码 如 1',
|
||||||
|
download_url VARCHAR(500) NOT NULL COMMENT 'APK/IPA 下载地址',
|
||||||
|
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
|
||||||
|
sha256 CHAR(64) NOT NULL COMMENT 'SHA-256 哈希值',
|
||||||
|
force_update TINYINT(1) DEFAULT 0 COMMENT '是否强制更新',
|
||||||
|
update_log TEXT COMMENT '更新日志',
|
||||||
|
release_date DATETIME NOT NULL COMMENT '发布时间',
|
||||||
|
is_active TINYINT(1) DEFAULT 1 COMMENT '是否激活',
|
||||||
|
download_count INT DEFAULT 0 COMMENT '下载次数',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_platform_version (platform, version_code),
|
||||||
|
INDEX idx_platform_active (platform, is_active)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用版本管理';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 遥测事件表 (analytics_events)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE analytics_events (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
event_id VARCHAR(36) NOT NULL COMMENT '事件唯一ID (UUID)',
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '事件名称',
|
||||||
|
type ENUM('page_view', 'user_action', 'api_call', 'performance', 'error', 'crash', 'session', 'presence') NOT NULL,
|
||||||
|
level ENUM('debug', 'info', 'warning', 'error') DEFAULT 'info',
|
||||||
|
install_id VARCHAR(100) NOT NULL COMMENT '设备安装ID',
|
||||||
|
device_context_id VARCHAR(100) COMMENT '设备上下文ID',
|
||||||
|
session_id VARCHAR(100) COMMENT '会话ID',
|
||||||
|
user_id VARCHAR(100) COMMENT '用户ID',
|
||||||
|
properties JSON COMMENT '事件属性',
|
||||||
|
event_time DATETIME(3) NOT NULL COMMENT '事件发生时间',
|
||||||
|
received_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '服务器接收时间',
|
||||||
|
|
||||||
|
UNIQUE KEY uk_event_id (event_id),
|
||||||
|
INDEX idx_install_id (install_id),
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_session_id (session_id),
|
||||||
|
INDEX idx_type_time (type, event_time),
|
||||||
|
INDEX idx_event_time (event_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='遥测事件记录'
|
||||||
|
PARTITION BY RANGE (TO_DAYS(event_time)) (
|
||||||
|
PARTITION p_default VALUES LESS THAN MAXVALUE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
> 建议按天分区,便于数据清理和查询优化
|
||||||
|
|
||||||
|
### 3.3 设备上下文表 (device_contexts)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE device_contexts (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
context_id VARCHAR(100) NOT NULL COMMENT '上下文ID',
|
||||||
|
install_id VARCHAR(100) NOT NULL COMMENT '设备安装ID',
|
||||||
|
platform VARCHAR(20) NOT NULL COMMENT '平台 android/ios',
|
||||||
|
brand VARCHAR(50) COMMENT '品牌',
|
||||||
|
model VARCHAR(100) COMMENT '型号',
|
||||||
|
manufacturer VARCHAR(100) COMMENT '制造商',
|
||||||
|
is_physical_device TINYINT(1) COMMENT '是否真机',
|
||||||
|
os_version VARCHAR(50) COMMENT '系统版本',
|
||||||
|
sdk_int INT COMMENT 'SDK版本号',
|
||||||
|
android_id VARCHAR(100) COMMENT 'Android ID',
|
||||||
|
screen_width INT COMMENT '屏幕宽度',
|
||||||
|
screen_height INT COMMENT '屏幕高度',
|
||||||
|
screen_density DECIMAL(4,2) COMMENT '屏幕密度',
|
||||||
|
app_name VARCHAR(100) COMMENT '应用名称',
|
||||||
|
package_name VARCHAR(200) COMMENT '包名',
|
||||||
|
app_version VARCHAR(20) COMMENT '应用版本',
|
||||||
|
build_number VARCHAR(20) COMMENT '构建号',
|
||||||
|
build_mode VARCHAR(20) COMMENT '构建模式 debug/release',
|
||||||
|
locale VARCHAR(20) COMMENT '语言区域',
|
||||||
|
timezone VARCHAR(50) COMMENT '时区',
|
||||||
|
network_type VARCHAR(20) COMMENT '网络类型',
|
||||||
|
is_dark_mode TINYINT(1) COMMENT '是否深色模式',
|
||||||
|
collected_at DATETIME(3) NOT NULL COMMENT '采集时间',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_context_id (context_id),
|
||||||
|
INDEX idx_install_id (install_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='设备上下文信息';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 日活统计表 (analytics_daily_active)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE analytics_daily_active (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
stat_date DATE NOT NULL COMMENT '统计日期',
|
||||||
|
total_dau INT DEFAULT 0 COMMENT '总DAU',
|
||||||
|
new_users INT DEFAULT 0 COMMENT '新用户数',
|
||||||
|
returning_users INT DEFAULT 0 COMMENT '回访用户数',
|
||||||
|
total_sessions INT DEFAULT 0 COMMENT '总会话数',
|
||||||
|
avg_session_duration INT DEFAULT 0 COMMENT '平均会话时长(秒)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_stat_date (stat_date)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日活统计汇总';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 在线快照表 (analytics_online_snapshots)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE analytics_online_snapshots (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
snapshot_time DATETIME NOT NULL COMMENT '快照时间',
|
||||||
|
online_count INT NOT NULL COMMENT '在线人数',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_snapshot_time (snapshot_time)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在线人数快照';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Redis 数据结构
|
||||||
|
|
||||||
|
### 4.1 在线用户集合 (实时在线统计)
|
||||||
|
|
||||||
|
**Key**: `presence:online_users`
|
||||||
|
**Type**: Sorted Set (ZSET)
|
||||||
|
**Score**: 最后心跳时间戳 (Unix timestamp)
|
||||||
|
**Member**: `installId` 或 `userId` (取决于是否登录)
|
||||||
|
|
||||||
|
```redis
|
||||||
|
# 用户发送心跳时
|
||||||
|
ZADD presence:online_users <timestamp> <installId>
|
||||||
|
|
||||||
|
# 获取在线人数 (2分钟内有心跳的用户)
|
||||||
|
ZCOUNT presence:online_users <now - 120> <now>
|
||||||
|
|
||||||
|
# 清理过期用户 (可选,定时任务执行)
|
||||||
|
ZREMRANGEBYSCORE presence:online_users -inf <now - 120>
|
||||||
|
```
|
||||||
|
|
||||||
|
**在线判定规则**
|
||||||
|
|
||||||
|
用户最后心跳时间在 **2 分钟** 内视为在线(心跳间隔 60 秒,允许 1 次丢失)
|
||||||
|
|
||||||
|
### 4.2 日活去重集合
|
||||||
|
|
||||||
|
**Key**: `analytics:dau:<date>` (如 `analytics:dau:2024-01-15`)
|
||||||
|
**Type**: Set
|
||||||
|
**Member**: `installId`
|
||||||
|
**TTL**: 48 小时
|
||||||
|
|
||||||
|
```redis
|
||||||
|
# 记录日活
|
||||||
|
SADD analytics:dau:2024-01-15 <installId>
|
||||||
|
EXPIRE analytics:dau:2024-01-15 172800
|
||||||
|
|
||||||
|
# 获取当日DAU
|
||||||
|
SCARD analytics:dau:2024-01-15
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 会话缓存
|
||||||
|
|
||||||
|
**Key**: `session:<sessionId>`
|
||||||
|
**Type**: Hash
|
||||||
|
**TTL**: 30 分钟 (随活动更新)
|
||||||
|
|
||||||
|
```redis
|
||||||
|
HSET session:xyz789
|
||||||
|
installId "device-xxx"
|
||||||
|
userId "user-123"
|
||||||
|
startTime "2024-01-15T10:00:00Z"
|
||||||
|
lastActiveTime "2024-01-15T10:30:00Z"
|
||||||
|
|
||||||
|
EXPIRE session:xyz789 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 定时任务
|
||||||
|
|
||||||
|
### 5.1 DAU 统计任务
|
||||||
|
|
||||||
|
**执行频率**: 每天凌晨 00:05
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 伪代码
|
||||||
|
def calculate_daily_dau():
|
||||||
|
yesterday = date.today() - timedelta(days=1)
|
||||||
|
date_str = yesterday.strftime('%Y-%m-%d')
|
||||||
|
|
||||||
|
# 从 Redis 获取昨日 DAU
|
||||||
|
dau_key = f'analytics:dau:{date_str}'
|
||||||
|
total_dau = redis.scard(dau_key)
|
||||||
|
|
||||||
|
# 或从数据库统计 (session_start 事件去重)
|
||||||
|
total_dau = db.query("""
|
||||||
|
SELECT COUNT(DISTINCT install_id)
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE type = 'session'
|
||||||
|
AND name = 'session_start'
|
||||||
|
AND DATE(event_time) = %s
|
||||||
|
""", [date_str])
|
||||||
|
|
||||||
|
# 统计新用户 (首次出现的 install_id)
|
||||||
|
new_users = db.query("""
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT install_id
|
||||||
|
FROM analytics_events
|
||||||
|
WHERE type = 'session' AND name = 'session_start'
|
||||||
|
GROUP BY install_id
|
||||||
|
HAVING MIN(DATE(event_time)) = %s
|
||||||
|
) t
|
||||||
|
""", [date_str])
|
||||||
|
|
||||||
|
# 写入统计表
|
||||||
|
db.upsert('analytics_daily_active', {
|
||||||
|
'stat_date': date_str,
|
||||||
|
'total_dau': total_dau,
|
||||||
|
'new_users': new_users,
|
||||||
|
'returning_users': total_dau - new_users
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 在线人数快照任务
|
||||||
|
|
||||||
|
**执行频率**: 每 5 分钟
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 伪代码
|
||||||
|
def snapshot_online_count():
|
||||||
|
now = datetime.now()
|
||||||
|
threshold = now - timedelta(minutes=2)
|
||||||
|
|
||||||
|
# 从 Redis 获取在线人数
|
||||||
|
online_count = redis.zcount(
|
||||||
|
'presence:online_users',
|
||||||
|
threshold.timestamp(),
|
||||||
|
now.timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 写入快照表
|
||||||
|
db.insert('analytics_online_snapshots', {
|
||||||
|
'snapshot_time': now,
|
||||||
|
'online_count': online_count
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 过期数据清理任务
|
||||||
|
|
||||||
|
**执行频率**: 每天凌晨 03:00
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 伪代码
|
||||||
|
def cleanup_expired_data():
|
||||||
|
# 清理 30 天前的事件数据
|
||||||
|
retention_days = 30
|
||||||
|
cutoff_date = date.today() - timedelta(days=retention_days)
|
||||||
|
|
||||||
|
db.execute("""
|
||||||
|
DELETE FROM analytics_events
|
||||||
|
WHERE event_time < %s
|
||||||
|
LIMIT 100000
|
||||||
|
""", [cutoff_date])
|
||||||
|
|
||||||
|
# 清理 Redis 过期在线状态
|
||||||
|
redis.zremrangebyscore(
|
||||||
|
'presence:online_users',
|
||||||
|
'-inf',
|
||||||
|
(datetime.now() - timedelta(minutes=5)).timestamp()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 前端关键行为说明
|
||||||
|
|
||||||
|
### 6.1 会话管理
|
||||||
|
|
||||||
|
**会话开始条件**:
|
||||||
|
- 应用冷启动
|
||||||
|
- 应用从后台恢复且距上次活动超过 30 分钟
|
||||||
|
|
||||||
|
**会话结束条件**:
|
||||||
|
- 应用进入后台
|
||||||
|
|
||||||
|
**前端发送事件**:
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// 会话开始
|
||||||
|
TelemetryService().trackEvent(
|
||||||
|
name: SessionEvents.sessionStart,
|
||||||
|
type: EventType.session,
|
||||||
|
properties: {'trigger': 'app_launch'},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 会话结束
|
||||||
|
TelemetryService().trackEvent(
|
||||||
|
name: SessionEvents.sessionEnd,
|
||||||
|
type: EventType.session,
|
||||||
|
properties: {
|
||||||
|
'duration_seconds': duration,
|
||||||
|
'trigger': 'app_pause',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 事件上报策略
|
||||||
|
|
||||||
|
- **队列阈值触发**: 本地队列 ≥10 条时触发上传
|
||||||
|
- **定时触发**: 每 30 秒检查一次
|
||||||
|
- **应用退出触发**: 强制上传全部队列
|
||||||
|
- **批量大小**: 每批最多 20 条
|
||||||
|
|
||||||
|
### 6.3 设备上下文
|
||||||
|
|
||||||
|
前端采集设备信息后生成唯一 `deviceContextId`,同一设备配置不变时复用相同 ID。设备信息变化(如系统升级)时生成新 ID。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API 错误码规范
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 0 | 成功 |
|
||||||
|
| 1001 | 参数错误 |
|
||||||
|
| 1002 | 认证失败 |
|
||||||
|
| 2001 | 服务器内部错误 |
|
||||||
|
| 2002 | 数据库错误 |
|
||||||
|
| 3001 | 版本不存在 |
|
||||||
|
| 3002 | 下载链接已过期 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 安全建议
|
||||||
|
|
||||||
|
1. **APK 下载**
|
||||||
|
- 使用 HTTPS
|
||||||
|
- 提供 SHA-256 校验
|
||||||
|
- 下载链接可设置有效期
|
||||||
|
|
||||||
|
2. **遥测数据**
|
||||||
|
- 不采集敏感个人信息 (如通讯录、短信)
|
||||||
|
- installId 使用 UUID 生成,不关联设备硬件标识
|
||||||
|
- 支持用户关闭遥测 (通过远程配置)
|
||||||
|
|
||||||
|
3. **心跳接口**
|
||||||
|
- 防刷限流 (每个 installId 每分钟最多 2 次)
|
||||||
|
- 异常流量监控
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录: 前端代码目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/core/
|
||||||
|
├── updater/ # APK升级模块
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── version_info.dart # 版本信息模型
|
||||||
|
│ │ └── update_config.dart # 更新配置模型
|
||||||
|
│ ├── channels/
|
||||||
|
│ │ ├── google_play_updater.dart # Google Play更新器
|
||||||
|
│ │ └── self_hosted_updater.dart # 自建服务器更新器
|
||||||
|
│ ├── version_checker.dart # 版本检测器
|
||||||
|
│ ├── download_manager.dart # 下载管理器
|
||||||
|
│ ├── apk_installer.dart # APK安装器
|
||||||
|
│ ├── app_market_detector.dart # 应用市场检测
|
||||||
|
│ └── update_service.dart # 统一更新服务
|
||||||
|
│
|
||||||
|
└── telemetry/ # 遥测模块
|
||||||
|
├── models/
|
||||||
|
│ ├── device_context.dart # 设备上下文模型
|
||||||
|
│ ├── telemetry_event.dart # 遥测事件模型
|
||||||
|
│ └── telemetry_config.dart # 遥测配置模型
|
||||||
|
├── collectors/
|
||||||
|
│ └── device_info_collector.dart # 设备信息采集器
|
||||||
|
├── storage/
|
||||||
|
│ └── telemetry_storage.dart # 本地事件存储
|
||||||
|
├── uploader/
|
||||||
|
│ └── telemetry_uploader.dart # 事件上传器
|
||||||
|
├── session/
|
||||||
|
│ ├── session_events.dart # 会话事件常量
|
||||||
|
│ └── session_manager.dart # 会话管理器
|
||||||
|
├── presence/
|
||||||
|
│ ├── presence_config.dart # 心跳配置
|
||||||
|
│ └── heartbeat_service.dart # 心跳服务
|
||||||
|
└── telemetry_service.dart # 遥测服务入口
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,6 +6,12 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'core/storage/local_storage.dart';
|
import 'core/storage/local_storage.dart';
|
||||||
import 'core/di/injection_container.dart';
|
import 'core/di/injection_container.dart';
|
||||||
import 'core/utils/logger.dart';
|
import 'core/utils/logger.dart';
|
||||||
|
import 'core/updater/update_service.dart';
|
||||||
|
import 'core/updater/models/update_config.dart';
|
||||||
|
import 'core/telemetry/telemetry_service.dart';
|
||||||
|
|
||||||
|
/// API 基础地址(正式环境应该从配置读取)
|
||||||
|
const String _apiBaseUrl = 'https://api.rwadurian.com';
|
||||||
|
|
||||||
Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
||||||
// Ensure Flutter bindings are initialized
|
// Ensure Flutter bindings are initialized
|
||||||
|
|
@ -33,12 +39,30 @@ Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
||||||
// Initialize LocalStorage
|
// Initialize LocalStorage
|
||||||
final localStorage = await LocalStorage.init();
|
final localStorage = await LocalStorage.init();
|
||||||
|
|
||||||
|
// Initialize UpdateService (自建服务器模式)
|
||||||
|
UpdateService().initialize(
|
||||||
|
UpdateConfig.selfHosted(
|
||||||
|
apiBaseUrl: _apiBaseUrl,
|
||||||
|
enabled: true,
|
||||||
|
checkIntervalSeconds: 86400, // 24小时
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Create provider container with initialized dependencies
|
// Create provider container with initialized dependencies
|
||||||
final container = createProviderContainer(localStorage);
|
final container = createProviderContainer(localStorage);
|
||||||
|
|
||||||
// Run app with error handling
|
// Run app with error handling
|
||||||
FlutterError.onError = (details) {
|
FlutterError.onError = (details) {
|
||||||
AppLogger.e('Flutter Error', details.exception, details.stack);
|
AppLogger.e('Flutter Error', details.exception, details.stack);
|
||||||
|
// 上报错误到遥测服务
|
||||||
|
if (TelemetryService().isInitialized) {
|
||||||
|
TelemetryService().logError(
|
||||||
|
'Flutter error',
|
||||||
|
error: details.exception,
|
||||||
|
stackTrace: details.stack,
|
||||||
|
extra: {'context': details.context?.toString()},
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
|
|
@ -48,3 +72,27 @@ Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 初始化遥测服务(需要 BuildContext,在首屏加载后调用)
|
||||||
|
Future<void> initializeTelemetry(BuildContext context, {String? userId}) async {
|
||||||
|
if (TelemetryService().isInitialized) return;
|
||||||
|
|
||||||
|
await TelemetryService().initialize(
|
||||||
|
apiBaseUrl: _apiBaseUrl,
|
||||||
|
context: context,
|
||||||
|
userId: userId,
|
||||||
|
configSyncInterval: const Duration(hours: 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查应用更新
|
||||||
|
Future<void> checkForAppUpdate(BuildContext context) async {
|
||||||
|
if (!UpdateService().isInitialized) return;
|
||||||
|
|
||||||
|
// 延迟几秒后检查更新,避免干扰用户
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
await UpdateService().checkForUpdate(context);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/device_context.dart';
|
||||||
|
|
||||||
|
/// 设备信息收集器
|
||||||
|
/// 负责收集完整的设备上下文信息
|
||||||
|
class DeviceInfoCollector {
|
||||||
|
static DeviceInfoCollector? _instance;
|
||||||
|
DeviceInfoCollector._();
|
||||||
|
|
||||||
|
factory DeviceInfoCollector() {
|
||||||
|
_instance ??= DeviceInfoCollector._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceContext? _cachedContext;
|
||||||
|
|
||||||
|
/// 收集完整设备上下文(首次会缓存)
|
||||||
|
Future<DeviceContext> collect(BuildContext context) async {
|
||||||
|
if (_cachedContext != null) return _cachedContext!;
|
||||||
|
|
||||||
|
final deviceInfo = DeviceInfoPlugin();
|
||||||
|
final packageInfo = await PackageInfo.fromPlatform();
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
DeviceContext result;
|
||||||
|
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final androidInfo = await deviceInfo.androidInfo;
|
||||||
|
|
||||||
|
result = DeviceContext(
|
||||||
|
platform: 'android',
|
||||||
|
brand: androidInfo.brand,
|
||||||
|
model: androidInfo.model,
|
||||||
|
manufacturer: androidInfo.manufacturer,
|
||||||
|
isPhysicalDevice: androidInfo.isPhysicalDevice,
|
||||||
|
osVersion: androidInfo.version.release,
|
||||||
|
sdkInt: androidInfo.version.sdkInt,
|
||||||
|
androidId: androidInfo.id, // 匿名ID,不是IMEI
|
||||||
|
screen: _collectScreenInfo(mediaQuery),
|
||||||
|
appName: packageInfo.appName,
|
||||||
|
packageName: packageInfo.packageName,
|
||||||
|
appVersion: packageInfo.version,
|
||||||
|
buildNumber: packageInfo.buildNumber,
|
||||||
|
buildMode: _getBuildMode(),
|
||||||
|
locale: Platform.localeName,
|
||||||
|
timezone: DateTime.now().timeZoneName,
|
||||||
|
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||||
|
networkType: 'unknown', // 需要额外的connectivity包
|
||||||
|
collectedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
final iosInfo = await deviceInfo.iosInfo;
|
||||||
|
|
||||||
|
result = DeviceContext(
|
||||||
|
platform: 'ios',
|
||||||
|
brand: 'Apple',
|
||||||
|
model: iosInfo.model,
|
||||||
|
manufacturer: 'Apple',
|
||||||
|
isPhysicalDevice: iosInfo.isPhysicalDevice,
|
||||||
|
osVersion: iosInfo.systemVersion,
|
||||||
|
sdkInt: 0, // iOS没有SDK版本号
|
||||||
|
androidId: iosInfo.identifierForVendor ?? '',
|
||||||
|
screen: _collectScreenInfo(mediaQuery),
|
||||||
|
appName: packageInfo.appName,
|
||||||
|
packageName: packageInfo.packageName,
|
||||||
|
appVersion: packageInfo.version,
|
||||||
|
buildNumber: packageInfo.buildNumber,
|
||||||
|
buildMode: _getBuildMode(),
|
||||||
|
locale: Platform.localeName,
|
||||||
|
timezone: DateTime.now().timeZoneName,
|
||||||
|
isDarkMode: mediaQuery.platformBrightness == Brightness.dark,
|
||||||
|
networkType: 'unknown',
|
||||||
|
collectedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw UnsupportedError('Unsupported platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
_cachedContext = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收集屏幕信息
|
||||||
|
ScreenInfo _collectScreenInfo(MediaQueryData mediaQuery) {
|
||||||
|
final size = mediaQuery.size;
|
||||||
|
final density = mediaQuery.devicePixelRatio;
|
||||||
|
|
||||||
|
return ScreenInfo(
|
||||||
|
widthPx: size.width * density,
|
||||||
|
heightPx: size.height * density,
|
||||||
|
density: density,
|
||||||
|
widthDp: size.width,
|
||||||
|
heightDp: size.height,
|
||||||
|
hasNotch: mediaQuery.padding.top > 24, // 简单判断刘海屏
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取构建模式
|
||||||
|
String _getBuildMode() {
|
||||||
|
if (kReleaseMode) return 'release';
|
||||||
|
if (kProfileMode) return 'profile';
|
||||||
|
return 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除缓存(版本更新时调用)
|
||||||
|
void clearCache() {
|
||||||
|
_cachedContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取缓存的上下文
|
||||||
|
DeviceContext? get cachedContext => _cachedContext;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,174 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// 屏幕信息
|
||||||
|
class ScreenInfo extends Equatable {
|
||||||
|
final double widthPx;
|
||||||
|
final double heightPx;
|
||||||
|
final double density;
|
||||||
|
final double widthDp;
|
||||||
|
final double heightDp;
|
||||||
|
final bool hasNotch;
|
||||||
|
|
||||||
|
const ScreenInfo({
|
||||||
|
required this.widthPx,
|
||||||
|
required this.heightPx,
|
||||||
|
required this.density,
|
||||||
|
required this.widthDp,
|
||||||
|
required this.heightDp,
|
||||||
|
required this.hasNotch,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ScreenInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ScreenInfo(
|
||||||
|
widthPx: (json['widthPx'] as num).toDouble(),
|
||||||
|
heightPx: (json['heightPx'] as num).toDouble(),
|
||||||
|
density: (json['density'] as num).toDouble(),
|
||||||
|
widthDp: (json['widthDp'] as num).toDouble(),
|
||||||
|
heightDp: (json['heightDp'] as num).toDouble(),
|
||||||
|
hasNotch: json['hasNotch'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'widthPx': widthPx,
|
||||||
|
'heightPx': heightPx,
|
||||||
|
'density': density,
|
||||||
|
'widthDp': widthDp,
|
||||||
|
'heightDp': heightDp,
|
||||||
|
'hasNotch': hasNotch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props =>
|
||||||
|
[widthPx, heightPx, density, widthDp, heightDp, hasNotch];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设备上下文
|
||||||
|
/// 包含完整的设备信息,用于兼容性分析
|
||||||
|
class DeviceContext extends Equatable {
|
||||||
|
// 设备信息
|
||||||
|
final String platform; // 'android' | 'ios'
|
||||||
|
final String brand; // 'Samsung'
|
||||||
|
final String model; // 'SM-G9980'
|
||||||
|
final String manufacturer; // 'samsung'
|
||||||
|
final bool isPhysicalDevice; // true
|
||||||
|
|
||||||
|
// 系统信息
|
||||||
|
final String osVersion; // '14'
|
||||||
|
final int sdkInt; // 34
|
||||||
|
final String androidId; // 匿名设备ID
|
||||||
|
|
||||||
|
// 屏幕信息
|
||||||
|
final ScreenInfo screen;
|
||||||
|
|
||||||
|
// App信息
|
||||||
|
final String appName;
|
||||||
|
final String packageName;
|
||||||
|
final String appVersion;
|
||||||
|
final String buildNumber;
|
||||||
|
final String buildMode; // 'debug' | 'profile' | 'release'
|
||||||
|
|
||||||
|
// 用户环境
|
||||||
|
final String locale; // 'zh_CN'
|
||||||
|
final String timezone; // 'Asia/Shanghai'
|
||||||
|
final bool isDarkMode;
|
||||||
|
final String networkType; // 'wifi' | 'cellular' | 'none'
|
||||||
|
|
||||||
|
// 时间戳
|
||||||
|
final DateTime collectedAt;
|
||||||
|
|
||||||
|
const DeviceContext({
|
||||||
|
required this.platform,
|
||||||
|
required this.brand,
|
||||||
|
required this.model,
|
||||||
|
required this.manufacturer,
|
||||||
|
required this.isPhysicalDevice,
|
||||||
|
required this.osVersion,
|
||||||
|
required this.sdkInt,
|
||||||
|
required this.androidId,
|
||||||
|
required this.screen,
|
||||||
|
required this.appName,
|
||||||
|
required this.packageName,
|
||||||
|
required this.appVersion,
|
||||||
|
required this.buildNumber,
|
||||||
|
required this.buildMode,
|
||||||
|
required this.locale,
|
||||||
|
required this.timezone,
|
||||||
|
required this.isDarkMode,
|
||||||
|
required this.networkType,
|
||||||
|
required this.collectedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DeviceContext.fromJson(Map<String, dynamic> json) {
|
||||||
|
return DeviceContext(
|
||||||
|
platform: json['platform'] as String,
|
||||||
|
brand: json['brand'] as String,
|
||||||
|
model: json['model'] as String,
|
||||||
|
manufacturer: json['manufacturer'] as String,
|
||||||
|
isPhysicalDevice: json['isPhysicalDevice'] as bool,
|
||||||
|
osVersion: json['osVersion'] as String,
|
||||||
|
sdkInt: json['sdkInt'] as int,
|
||||||
|
androidId: json['androidId'] as String,
|
||||||
|
screen: ScreenInfo.fromJson(json['screen'] as Map<String, dynamic>),
|
||||||
|
appName: json['appName'] as String,
|
||||||
|
packageName: json['packageName'] as String,
|
||||||
|
appVersion: json['appVersion'] as String,
|
||||||
|
buildNumber: json['buildNumber'] as String,
|
||||||
|
buildMode: json['buildMode'] as String,
|
||||||
|
locale: json['locale'] as String,
|
||||||
|
timezone: json['timezone'] as String,
|
||||||
|
isDarkMode: json['isDarkMode'] as bool,
|
||||||
|
networkType: json['networkType'] as String,
|
||||||
|
collectedAt: DateTime.parse(json['collectedAt'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'platform': platform,
|
||||||
|
'brand': brand,
|
||||||
|
'model': model,
|
||||||
|
'manufacturer': manufacturer,
|
||||||
|
'isPhysicalDevice': isPhysicalDevice,
|
||||||
|
'osVersion': osVersion,
|
||||||
|
'sdkInt': sdkInt,
|
||||||
|
'androidId': androidId,
|
||||||
|
'screen': screen.toJson(),
|
||||||
|
'appName': appName,
|
||||||
|
'packageName': packageName,
|
||||||
|
'appVersion': appVersion,
|
||||||
|
'buildNumber': buildNumber,
|
||||||
|
'buildMode': buildMode,
|
||||||
|
'locale': locale,
|
||||||
|
'timezone': timezone,
|
||||||
|
'isDarkMode': isDarkMode,
|
||||||
|
'networkType': networkType,
|
||||||
|
'collectedAt': collectedAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
platform,
|
||||||
|
brand,
|
||||||
|
model,
|
||||||
|
manufacturer,
|
||||||
|
isPhysicalDevice,
|
||||||
|
osVersion,
|
||||||
|
sdkInt,
|
||||||
|
androidId,
|
||||||
|
screen,
|
||||||
|
appName,
|
||||||
|
packageName,
|
||||||
|
appVersion,
|
||||||
|
buildNumber,
|
||||||
|
buildMode,
|
||||||
|
locale,
|
||||||
|
timezone,
|
||||||
|
isDarkMode,
|
||||||
|
networkType,
|
||||||
|
collectedAt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'telemetry_event.dart';
|
||||||
|
import '../presence/presence_config.dart';
|
||||||
|
|
||||||
|
/// 遥测配置
|
||||||
|
/// 支持远程配置和本地缓存
|
||||||
|
class TelemetryConfig {
|
||||||
|
// 全局开关
|
||||||
|
bool globalEnabled = true;
|
||||||
|
|
||||||
|
// 分类型开关
|
||||||
|
bool errorReportEnabled = true; // 错误上报
|
||||||
|
bool performanceEnabled = true; // 性能监控
|
||||||
|
bool userActionEnabled = true; // 用户行为
|
||||||
|
bool pageViewEnabled = true; // 页面访问
|
||||||
|
bool sessionEnabled = true; // 会话事件(DAU相关)
|
||||||
|
|
||||||
|
// 采样配置
|
||||||
|
double samplingRate = 0.1; // 10% 采样率
|
||||||
|
|
||||||
|
// 事件黑名单
|
||||||
|
List<String> disabledEvents = [];
|
||||||
|
|
||||||
|
// 配置版本
|
||||||
|
String configVersion = '1.0.0';
|
||||||
|
|
||||||
|
// 用户是否同意(可选,用于隐私合规)
|
||||||
|
bool userOptIn = true;
|
||||||
|
|
||||||
|
// 心跳/在线状态配置
|
||||||
|
PresenceConfig? presenceConfig;
|
||||||
|
|
||||||
|
static final TelemetryConfig _instance = TelemetryConfig._();
|
||||||
|
TelemetryConfig._();
|
||||||
|
factory TelemetryConfig() => _instance;
|
||||||
|
|
||||||
|
/// 从后端同步配置
|
||||||
|
Future<void> syncFromRemote(String apiBaseUrl) async {
|
||||||
|
try {
|
||||||
|
final dio = Dio(BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
final response = await dio.get('$apiBaseUrl/api/telemetry/config');
|
||||||
|
final data = response.data;
|
||||||
|
|
||||||
|
globalEnabled = data['global_enabled'] ?? true;
|
||||||
|
errorReportEnabled = data['error_report_enabled'] ?? true;
|
||||||
|
performanceEnabled = data['performance_enabled'] ?? true;
|
||||||
|
userActionEnabled = data['user_action_enabled'] ?? true;
|
||||||
|
pageViewEnabled = data['page_view_enabled'] ?? true;
|
||||||
|
sessionEnabled = data['session_enabled'] ?? true;
|
||||||
|
samplingRate = (data['sampling_rate'] ?? 0.1).toDouble();
|
||||||
|
disabledEvents = List<String>.from(data['disabled_events'] ?? []);
|
||||||
|
configVersion = data['version'] ?? '1.0.0';
|
||||||
|
|
||||||
|
// 解析心跳配置
|
||||||
|
if (data['presence_config'] != null) {
|
||||||
|
presenceConfig = PresenceConfig.fromJson(data['presence_config']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存到本地
|
||||||
|
await _saveToLocal();
|
||||||
|
|
||||||
|
debugPrint('📊 Telemetry config synced (v$configVersion)');
|
||||||
|
debugPrint(
|
||||||
|
' Global: $globalEnabled, Sampling: ${(samplingRate * 100).toInt()}%');
|
||||||
|
debugPrint(' Presence: ${presenceConfig?.enabled ?? true}');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ Failed to sync telemetry config: $e');
|
||||||
|
// 失败时加载本地缓存
|
||||||
|
await _loadFromLocal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存到本地
|
||||||
|
Future<void> _saveToLocal() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('telemetry_global_enabled', globalEnabled);
|
||||||
|
await prefs.setBool('telemetry_error_enabled', errorReportEnabled);
|
||||||
|
await prefs.setBool('telemetry_performance_enabled', performanceEnabled);
|
||||||
|
await prefs.setBool('telemetry_user_action_enabled', userActionEnabled);
|
||||||
|
await prefs.setBool('telemetry_page_view_enabled', pageViewEnabled);
|
||||||
|
await prefs.setBool('telemetry_session_enabled', sessionEnabled);
|
||||||
|
await prefs.setDouble('telemetry_sampling_rate', samplingRate);
|
||||||
|
await prefs.setStringList('telemetry_disabled_events', disabledEvents);
|
||||||
|
await prefs.setString('telemetry_config_version', configVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从本地加载
|
||||||
|
Future<void> _loadFromLocal() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
globalEnabled = prefs.getBool('telemetry_global_enabled') ?? true;
|
||||||
|
errorReportEnabled = prefs.getBool('telemetry_error_enabled') ?? true;
|
||||||
|
performanceEnabled =
|
||||||
|
prefs.getBool('telemetry_performance_enabled') ?? true;
|
||||||
|
userActionEnabled = prefs.getBool('telemetry_user_action_enabled') ?? true;
|
||||||
|
pageViewEnabled = prefs.getBool('telemetry_page_view_enabled') ?? true;
|
||||||
|
sessionEnabled = prefs.getBool('telemetry_session_enabled') ?? true;
|
||||||
|
samplingRate = prefs.getDouble('telemetry_sampling_rate') ?? 0.1;
|
||||||
|
disabledEvents = prefs.getStringList('telemetry_disabled_events') ?? [];
|
||||||
|
configVersion = prefs.getString('telemetry_config_version') ?? '1.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否应该记录该事件
|
||||||
|
bool shouldLog(EventType type, String eventName) {
|
||||||
|
// 1. 全局开关
|
||||||
|
if (!globalEnabled) return false;
|
||||||
|
|
||||||
|
// 2. 用户未同意
|
||||||
|
if (!userOptIn) return false;
|
||||||
|
|
||||||
|
// 3. 事件黑名单
|
||||||
|
if (disabledEvents.contains(eventName)) return false;
|
||||||
|
|
||||||
|
// 4. 分类型判断
|
||||||
|
switch (type) {
|
||||||
|
case EventType.error:
|
||||||
|
case EventType.crash:
|
||||||
|
return errorReportEnabled;
|
||||||
|
case EventType.performance:
|
||||||
|
return performanceEnabled;
|
||||||
|
case EventType.userAction:
|
||||||
|
return userActionEnabled;
|
||||||
|
case EventType.pageView:
|
||||||
|
return pageViewEnabled;
|
||||||
|
case EventType.apiCall:
|
||||||
|
return performanceEnabled; // API调用归入性能监控
|
||||||
|
case EventType.session:
|
||||||
|
return sessionEnabled; // 会话事件
|
||||||
|
case EventType.presence:
|
||||||
|
return presenceConfig?.enabled ?? true; // 在线状态
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置用户是否同意
|
||||||
|
Future<void> setUserOptIn(bool optIn) async {
|
||||||
|
userOptIn = optIn;
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('telemetry_user_opt_in', optIn);
|
||||||
|
debugPrint('📊 User opt-in: $optIn');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 加载用户选择
|
||||||
|
Future<void> loadUserOptIn() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
userOptIn = prefs.getBool('telemetry_user_opt_in') ?? true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,159 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// 事件级别
|
||||||
|
enum EventLevel {
|
||||||
|
debug,
|
||||||
|
info,
|
||||||
|
warning,
|
||||||
|
error,
|
||||||
|
fatal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事件类型
|
||||||
|
enum EventType {
|
||||||
|
/// 页面访问
|
||||||
|
pageView,
|
||||||
|
|
||||||
|
/// 用户行为
|
||||||
|
userAction,
|
||||||
|
|
||||||
|
/// API请求
|
||||||
|
apiCall,
|
||||||
|
|
||||||
|
/// 性能指标
|
||||||
|
performance,
|
||||||
|
|
||||||
|
/// 错误异常
|
||||||
|
error,
|
||||||
|
|
||||||
|
/// 崩溃
|
||||||
|
crash,
|
||||||
|
|
||||||
|
/// 会话事件 (app_session_start, app_session_end)
|
||||||
|
session,
|
||||||
|
|
||||||
|
/// 在线状态 (心跳相关)
|
||||||
|
presence,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 遥测事件模型
|
||||||
|
class TelemetryEvent extends Equatable {
|
||||||
|
/// 事件ID (UUID)
|
||||||
|
final String eventId;
|
||||||
|
|
||||||
|
/// 事件类型
|
||||||
|
final EventType type;
|
||||||
|
|
||||||
|
/// 事件级别
|
||||||
|
final EventLevel level;
|
||||||
|
|
||||||
|
/// 事件名称: 'app_session_start', 'open_planting_page'
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// 事件参数
|
||||||
|
final Map<String, dynamic>? properties;
|
||||||
|
|
||||||
|
/// 事件时间戳
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
/// 用户ID(登录后设置)
|
||||||
|
final String? userId;
|
||||||
|
|
||||||
|
/// 会话ID
|
||||||
|
final String? sessionId;
|
||||||
|
|
||||||
|
/// 安装ID(设备唯一标识)
|
||||||
|
final String installId;
|
||||||
|
|
||||||
|
/// 关联设备信息ID
|
||||||
|
final String deviceContextId;
|
||||||
|
|
||||||
|
const TelemetryEvent({
|
||||||
|
required this.eventId,
|
||||||
|
required this.type,
|
||||||
|
required this.level,
|
||||||
|
required this.name,
|
||||||
|
this.properties,
|
||||||
|
required this.timestamp,
|
||||||
|
this.userId,
|
||||||
|
this.sessionId,
|
||||||
|
required this.installId,
|
||||||
|
required this.deviceContextId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TelemetryEvent.fromJson(Map<String, dynamic> json) {
|
||||||
|
return TelemetryEvent(
|
||||||
|
eventId: json['eventId'] as String,
|
||||||
|
type: EventType.values.firstWhere(
|
||||||
|
(e) => e.name == json['type'],
|
||||||
|
orElse: () => EventType.userAction,
|
||||||
|
),
|
||||||
|
level: EventLevel.values.firstWhere(
|
||||||
|
(e) => e.name == json['level'],
|
||||||
|
orElse: () => EventLevel.info,
|
||||||
|
),
|
||||||
|
name: json['name'] as String,
|
||||||
|
properties: json['properties'] as Map<String, dynamic>?,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
userId: json['userId'] as String?,
|
||||||
|
sessionId: json['sessionId'] as String?,
|
||||||
|
installId: json['installId'] as String,
|
||||||
|
deviceContextId: json['deviceContextId'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'eventId': eventId,
|
||||||
|
'type': type.name,
|
||||||
|
'level': level.name,
|
||||||
|
'name': name,
|
||||||
|
'properties': properties,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
'userId': userId,
|
||||||
|
'sessionId': sessionId,
|
||||||
|
'installId': installId,
|
||||||
|
'deviceContextId': deviceContextId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
TelemetryEvent copyWith({
|
||||||
|
String? eventId,
|
||||||
|
EventType? type,
|
||||||
|
EventLevel? level,
|
||||||
|
String? name,
|
||||||
|
Map<String, dynamic>? properties,
|
||||||
|
DateTime? timestamp,
|
||||||
|
String? userId,
|
||||||
|
String? sessionId,
|
||||||
|
String? installId,
|
||||||
|
String? deviceContextId,
|
||||||
|
}) {
|
||||||
|
return TelemetryEvent(
|
||||||
|
eventId: eventId ?? this.eventId,
|
||||||
|
type: type ?? this.type,
|
||||||
|
level: level ?? this.level,
|
||||||
|
name: name ?? this.name,
|
||||||
|
properties: properties ?? this.properties,
|
||||||
|
timestamp: timestamp ?? this.timestamp,
|
||||||
|
userId: userId ?? this.userId,
|
||||||
|
sessionId: sessionId ?? this.sessionId,
|
||||||
|
installId: installId ?? this.installId,
|
||||||
|
deviceContextId: deviceContextId ?? this.deviceContextId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
eventId,
|
||||||
|
type,
|
||||||
|
level,
|
||||||
|
name,
|
||||||
|
properties,
|
||||||
|
timestamp,
|
||||||
|
userId,
|
||||||
|
sessionId,
|
||||||
|
installId,
|
||||||
|
deviceContextId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../session/session_manager.dart';
|
||||||
|
import '../session/session_events.dart';
|
||||||
|
import 'presence_config.dart';
|
||||||
|
|
||||||
|
/// 心跳服务
|
||||||
|
///
|
||||||
|
/// 职责:
|
||||||
|
/// 1. 在 App 前台时定期发送心跳
|
||||||
|
/// 2. 进入后台时停止心跳
|
||||||
|
/// 3. 心跳失败时不立即重试,等待下一个周期
|
||||||
|
class HeartbeatService {
|
||||||
|
static HeartbeatService? _instance;
|
||||||
|
|
||||||
|
HeartbeatService._();
|
||||||
|
|
||||||
|
factory HeartbeatService() {
|
||||||
|
_instance ??= HeartbeatService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 配置
|
||||||
|
PresenceConfig _config = PresenceConfig.defaultConfig;
|
||||||
|
|
||||||
|
/// 心跳定时器
|
||||||
|
Timer? _heartbeatTimer;
|
||||||
|
|
||||||
|
/// 是否正在运行
|
||||||
|
bool _isRunning = false;
|
||||||
|
bool get isRunning => _isRunning;
|
||||||
|
|
||||||
|
/// 最后一次心跳时间
|
||||||
|
DateTime? _lastHeartbeatAt;
|
||||||
|
DateTime? get lastHeartbeatAt => _lastHeartbeatAt;
|
||||||
|
|
||||||
|
/// 心跳计数(调试用)
|
||||||
|
int _heartbeatCount = 0;
|
||||||
|
int get heartbeatCount => _heartbeatCount;
|
||||||
|
|
||||||
|
/// API 基础地址
|
||||||
|
String? _apiBaseUrl;
|
||||||
|
|
||||||
|
/// 获取 installId 的回调
|
||||||
|
String Function()? getInstallId;
|
||||||
|
|
||||||
|
/// 获取 userId 的回调
|
||||||
|
String? Function()? getUserId;
|
||||||
|
|
||||||
|
/// 获取 appVersion 的回调
|
||||||
|
String Function()? getAppVersion;
|
||||||
|
|
||||||
|
/// 获取认证头的回调
|
||||||
|
Map<String, String> Function()? getAuthHeaders;
|
||||||
|
|
||||||
|
late Dio _dio;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 初始化
|
||||||
|
void initialize({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
PresenceConfig? config,
|
||||||
|
required String Function() getInstallId,
|
||||||
|
required String? Function() getUserId,
|
||||||
|
required String Function() getAppVersion,
|
||||||
|
Map<String, String> Function()? getAuthHeaders,
|
||||||
|
}) {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
_apiBaseUrl = apiBaseUrl;
|
||||||
|
_config = config ?? PresenceConfig.defaultConfig;
|
||||||
|
this.getInstallId = getInstallId;
|
||||||
|
this.getUserId = getUserId;
|
||||||
|
this.getAppVersion = getAppVersion;
|
||||||
|
this.getAuthHeaders = getAuthHeaders;
|
||||||
|
|
||||||
|
_dio = Dio(BaseOptions(
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 5),
|
||||||
|
receiveTimeout: const Duration(seconds: 5),
|
||||||
|
));
|
||||||
|
|
||||||
|
// 监听会话状态变化
|
||||||
|
final sessionManager = SessionManager();
|
||||||
|
sessionManager.onSessionStart = _onSessionStart;
|
||||||
|
sessionManager.onSessionEnd = _onSessionEnd;
|
||||||
|
|
||||||
|
// 如果当前已经在前台,立即启动心跳
|
||||||
|
if (sessionManager.state == SessionState.foreground) {
|
||||||
|
_startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint(
|
||||||
|
'💓 [Heartbeat] Initialized, interval: ${_config.heartbeatIntervalSeconds}s');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新配置(支持远程配置热更新)
|
||||||
|
void updateConfig(PresenceConfig config) {
|
||||||
|
final wasRunning = _isRunning;
|
||||||
|
|
||||||
|
if (wasRunning) {
|
||||||
|
_stopHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
_config = config;
|
||||||
|
|
||||||
|
if (wasRunning && _config.enabled) {
|
||||||
|
_startHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('💓 [Heartbeat] Config updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 销毁
|
||||||
|
void dispose() {
|
||||||
|
_stopHeartbeat();
|
||||||
|
_isInitialized = false;
|
||||||
|
_instance = null;
|
||||||
|
debugPrint('💓 [Heartbeat] Disposed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话开始回调
|
||||||
|
void _onSessionStart() {
|
||||||
|
if (_config.enabled) {
|
||||||
|
_startHeartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话结束回调
|
||||||
|
void _onSessionEnd() {
|
||||||
|
_stopHeartbeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动心跳
|
||||||
|
void _startHeartbeat() {
|
||||||
|
if (_isRunning) return;
|
||||||
|
if (!_config.enabled) return;
|
||||||
|
|
||||||
|
_isRunning = true;
|
||||||
|
_heartbeatCount = 0;
|
||||||
|
|
||||||
|
// 立即发送第一次心跳
|
||||||
|
_sendHeartbeat();
|
||||||
|
|
||||||
|
// 启动定时器
|
||||||
|
_heartbeatTimer = Timer.periodic(
|
||||||
|
Duration(seconds: _config.heartbeatIntervalSeconds),
|
||||||
|
(_) => _sendHeartbeat(),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('💓 [Heartbeat] Started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止心跳
|
||||||
|
void _stopHeartbeat() {
|
||||||
|
_heartbeatTimer?.cancel();
|
||||||
|
_heartbeatTimer = null;
|
||||||
|
_isRunning = false;
|
||||||
|
|
||||||
|
debugPrint('💓 [Heartbeat] Stopped (count: $_heartbeatCount)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送心跳
|
||||||
|
Future<void> _sendHeartbeat() async {
|
||||||
|
// 检查是否需要登录
|
||||||
|
if (_config.requiresAuth && (getUserId?.call() == null)) {
|
||||||
|
debugPrint('💓 [Heartbeat] Skipped: user not logged in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/v1/presence/heartbeat',
|
||||||
|
data: {
|
||||||
|
'installId': getInstallId?.call() ?? '',
|
||||||
|
'appVersion': getAppVersion?.call() ?? '',
|
||||||
|
'clientTs': DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
headers: getAuthHeaders?.call(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
_lastHeartbeatAt = DateTime.now();
|
||||||
|
_heartbeatCount++;
|
||||||
|
debugPrint('💓 [Heartbeat] Sent #$_heartbeatCount');
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
// 心跳失败不重试,等待下一个周期
|
||||||
|
debugPrint('💓 [Heartbeat] Failed (DioException): ${e.message}');
|
||||||
|
} catch (e) {
|
||||||
|
// 心跳失败不重试,等待下一个周期
|
||||||
|
debugPrint('💓 [Heartbeat] Failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动触发心跳(用于测试)
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> forceHeartbeat() async {
|
||||||
|
await _sendHeartbeat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/// 心跳配置
|
||||||
|
class PresenceConfig {
|
||||||
|
/// 心跳间隔(秒)
|
||||||
|
/// 默认 60 秒,与后端 3 分钟窗口配合
|
||||||
|
final int heartbeatIntervalSeconds;
|
||||||
|
|
||||||
|
/// 是否仅登录用户发送心跳
|
||||||
|
/// 默认 true,未登录用户不参与在线统计
|
||||||
|
final bool requiresAuth;
|
||||||
|
|
||||||
|
/// 是否启用心跳
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
const PresenceConfig({
|
||||||
|
this.heartbeatIntervalSeconds = 60,
|
||||||
|
this.requiresAuth = true,
|
||||||
|
this.enabled = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 默认配置
|
||||||
|
static const PresenceConfig defaultConfig = PresenceConfig();
|
||||||
|
|
||||||
|
/// 从远程配置解析
|
||||||
|
factory PresenceConfig.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PresenceConfig(
|
||||||
|
heartbeatIntervalSeconds: json['heartbeat_interval_seconds'] ?? 60,
|
||||||
|
requiresAuth: json['requires_auth'] ?? true,
|
||||||
|
enabled: json['presence_enabled'] ?? json['enabled'] ?? true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'heartbeat_interval_seconds': heartbeatIntervalSeconds,
|
||||||
|
'requires_auth': requiresAuth,
|
||||||
|
'presence_enabled': enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PresenceConfig copyWith({
|
||||||
|
int? heartbeatIntervalSeconds,
|
||||||
|
bool? requiresAuth,
|
||||||
|
bool? enabled,
|
||||||
|
}) {
|
||||||
|
return PresenceConfig(
|
||||||
|
heartbeatIntervalSeconds:
|
||||||
|
heartbeatIntervalSeconds ?? this.heartbeatIntervalSeconds,
|
||||||
|
requiresAuth: requiresAuth ?? this.requiresAuth,
|
||||||
|
enabled: enabled ?? this.enabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/// 会话相关的事件名常量
|
||||||
|
class SessionEvents {
|
||||||
|
/// App 会话开始(用于 DAU 统计)
|
||||||
|
/// 触发时机:App 从后台切到前台,或冷启动
|
||||||
|
static const String sessionStart = 'app_session_start';
|
||||||
|
|
||||||
|
/// App 会话结束
|
||||||
|
/// 触发时机:App 进入后台
|
||||||
|
static const String sessionEnd = 'app_session_end';
|
||||||
|
|
||||||
|
/// 心跳事件(用于在线统计)
|
||||||
|
/// 触发时机:前台状态下每 60 秒
|
||||||
|
static const String heartbeat = 'presence_heartbeat';
|
||||||
|
|
||||||
|
/// 私有构造函数,防止实例化
|
||||||
|
SessionEvents._();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 会话状态
|
||||||
|
enum SessionState {
|
||||||
|
/// 前台活跃
|
||||||
|
foreground,
|
||||||
|
|
||||||
|
/// 后台
|
||||||
|
background,
|
||||||
|
|
||||||
|
/// 未知(初始状态)
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../telemetry_service.dart';
|
||||||
|
import '../models/telemetry_event.dart';
|
||||||
|
import 'session_events.dart';
|
||||||
|
|
||||||
|
/// 会话管理器
|
||||||
|
///
|
||||||
|
/// 职责:
|
||||||
|
/// 1. 监听 App 生命周期,触发 app_session_start/app_session_end 事件
|
||||||
|
/// 2. 管理 sessionId(每次前台生成新的)
|
||||||
|
/// 3. 与 HeartbeatService 联动(前台启动心跳,后台停止)
|
||||||
|
class SessionManager with WidgetsBindingObserver {
|
||||||
|
static SessionManager? _instance;
|
||||||
|
|
||||||
|
SessionManager._();
|
||||||
|
|
||||||
|
factory SessionManager() {
|
||||||
|
_instance ??= SessionManager._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 当前会话 ID
|
||||||
|
String? _currentSessionId;
|
||||||
|
String? get currentSessionId => _currentSessionId;
|
||||||
|
|
||||||
|
/// 当前会话状态
|
||||||
|
SessionState _state = SessionState.unknown;
|
||||||
|
SessionState get state => _state;
|
||||||
|
|
||||||
|
/// 会话开始时间
|
||||||
|
DateTime? _sessionStartTime;
|
||||||
|
|
||||||
|
/// 回调:会话开始(HeartbeatService 监听此回调)
|
||||||
|
VoidCallback? onSessionStart;
|
||||||
|
|
||||||
|
/// 回调:会话结束(HeartbeatService 监听此回调)
|
||||||
|
VoidCallback? onSessionEnd;
|
||||||
|
|
||||||
|
/// TelemetryService 引用
|
||||||
|
TelemetryService? _telemetryService;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 初始化
|
||||||
|
void initialize(TelemetryService telemetryService) {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
_telemetryService = telemetryService;
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
|
// 首次启动视为进入前台
|
||||||
|
_handleForeground();
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('📱 [Session] Manager initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 销毁
|
||||||
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_isInitialized = false;
|
||||||
|
_instance = null;
|
||||||
|
debugPrint('📱 [Session] Manager disposed');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
switch (state) {
|
||||||
|
case AppLifecycleState.resumed:
|
||||||
|
_handleForeground();
|
||||||
|
break;
|
||||||
|
case AppLifecycleState.paused:
|
||||||
|
_handleBackground();
|
||||||
|
break;
|
||||||
|
case AppLifecycleState.inactive:
|
||||||
|
case AppLifecycleState.detached:
|
||||||
|
case AppLifecycleState.hidden:
|
||||||
|
// 不处理这些中间状态
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理进入前台
|
||||||
|
void _handleForeground() {
|
||||||
|
if (_state == SessionState.foreground) return;
|
||||||
|
|
||||||
|
_state = SessionState.foreground;
|
||||||
|
_startNewSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 处理进入后台
|
||||||
|
void _handleBackground() {
|
||||||
|
if (_state == SessionState.background) return;
|
||||||
|
|
||||||
|
_state = SessionState.background;
|
||||||
|
_endCurrentSession();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始新会话
|
||||||
|
void _startNewSession() {
|
||||||
|
// 生成新的 sessionId
|
||||||
|
_currentSessionId = const Uuid().v4();
|
||||||
|
_sessionStartTime = DateTime.now();
|
||||||
|
|
||||||
|
// 记录 app_session_start 事件(用于 DAU)
|
||||||
|
_telemetryService?.logEvent(
|
||||||
|
SessionEvents.sessionStart,
|
||||||
|
type: EventType.session,
|
||||||
|
level: EventLevel.info,
|
||||||
|
properties: {
|
||||||
|
'session_id': _currentSessionId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通知外部(HeartbeatService 会监听这个回调)
|
||||||
|
onSessionStart?.call();
|
||||||
|
|
||||||
|
debugPrint('📱 [Session] Started: $_currentSessionId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结束当前会话
|
||||||
|
void _endCurrentSession() {
|
||||||
|
if (_currentSessionId == null) return;
|
||||||
|
|
||||||
|
final duration = _sessionStartTime != null
|
||||||
|
? DateTime.now().difference(_sessionStartTime!).inSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// 记录 app_session_end 事件
|
||||||
|
_telemetryService?.logEvent(
|
||||||
|
SessionEvents.sessionEnd,
|
||||||
|
type: EventType.session,
|
||||||
|
level: EventLevel.info,
|
||||||
|
properties: {
|
||||||
|
'session_id': _currentSessionId,
|
||||||
|
'duration_seconds': duration,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 通知外部(HeartbeatService 会监听这个回调)
|
||||||
|
onSessionEnd?.call();
|
||||||
|
|
||||||
|
debugPrint('📱 [Session] Ended: $_currentSessionId (${duration}s)');
|
||||||
|
|
||||||
|
_currentSessionId = null;
|
||||||
|
_sessionStartTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前会话时长(秒)
|
||||||
|
int get sessionDurationSeconds {
|
||||||
|
if (_sessionStartTime == null) return 0;
|
||||||
|
return DateTime.now().difference(_sessionStartTime!).inSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../models/telemetry_event.dart';
|
||||||
|
|
||||||
|
/// 遥测本地存储
|
||||||
|
/// 负责缓存事件队列和设备上下文
|
||||||
|
class TelemetryStorage {
|
||||||
|
static const String _keyEventQueue = 'telemetry_event_queue';
|
||||||
|
static const String _keyDeviceContext = 'telemetry_device_context';
|
||||||
|
static const String _keyInstallId = 'telemetry_install_id';
|
||||||
|
static const int _maxQueueSize = 500; // 最多缓存500条
|
||||||
|
|
||||||
|
late SharedPreferences _prefs;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 初始化
|
||||||
|
Future<void> init() async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
_isInitialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存设备上下文
|
||||||
|
Future<void> saveDeviceContext(Map<String, dynamic> context) async {
|
||||||
|
await _prefs.setString(_keyDeviceContext, jsonEncode(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取设备上下文
|
||||||
|
Map<String, dynamic>? getDeviceContext() {
|
||||||
|
final str = _prefs.getString(_keyDeviceContext);
|
||||||
|
if (str == null) return null;
|
||||||
|
return jsonDecode(str) as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 保存 InstallId
|
||||||
|
Future<void> saveInstallId(String installId) async {
|
||||||
|
await _prefs.setString(_keyInstallId, installId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取 InstallId
|
||||||
|
String? getInstallId() {
|
||||||
|
return _prefs.getString(_keyInstallId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 添加事件到队列
|
||||||
|
Future<void> enqueueEvent(TelemetryEvent event) async {
|
||||||
|
final queue = _getEventQueue();
|
||||||
|
|
||||||
|
// 防止队列过大
|
||||||
|
if (queue.length >= _maxQueueSize) {
|
||||||
|
queue.removeAt(0); // 移除最旧的
|
||||||
|
debugPrint('⚠️ Telemetry queue full, removed oldest event');
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.add(event.toJson());
|
||||||
|
await _saveEventQueue(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量添加事件
|
||||||
|
Future<void> enqueueEvents(List<TelemetryEvent> events) async {
|
||||||
|
final queue = _getEventQueue();
|
||||||
|
|
||||||
|
for (var event in events) {
|
||||||
|
if (queue.length >= _maxQueueSize) break;
|
||||||
|
queue.add(event.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _saveEventQueue(queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取待上传的事件(最多N条)
|
||||||
|
List<TelemetryEvent> dequeueEvents(int limit) {
|
||||||
|
final queue = _getEventQueue();
|
||||||
|
final count = queue.length > limit ? limit : queue.length;
|
||||||
|
|
||||||
|
final events = queue
|
||||||
|
.take(count)
|
||||||
|
.map((json) => TelemetryEvent.fromJson(json))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除已上传的事件
|
||||||
|
Future<void> removeEvents(int count) async {
|
||||||
|
final queue = _getEventQueue();
|
||||||
|
if (count >= queue.length) {
|
||||||
|
await clearEventQueue();
|
||||||
|
} else {
|
||||||
|
queue.removeRange(0, count);
|
||||||
|
await _saveEventQueue(queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取队列长度
|
||||||
|
int getQueueSize() {
|
||||||
|
return _getEventQueue().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清空事件队列
|
||||||
|
Future<void> clearEventQueue() async {
|
||||||
|
await _prefs.remove(_keyEventQueue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 私有方法
|
||||||
|
List<Map<String, dynamic>> _getEventQueue() {
|
||||||
|
final str = _prefs.getString(_keyEventQueue);
|
||||||
|
if (str == null) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final List<dynamic> list = jsonDecode(str);
|
||||||
|
return list.cast<Map<String, dynamic>>();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ Failed to parse event queue: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveEventQueue(List<Map<String, dynamic>> queue) async {
|
||||||
|
await _prefs.setString(_keyEventQueue, jsonEncode(queue));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:math';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'models/telemetry_event.dart';
|
||||||
|
import 'models/device_context.dart';
|
||||||
|
import 'models/telemetry_config.dart';
|
||||||
|
import 'collectors/device_info_collector.dart';
|
||||||
|
import 'storage/telemetry_storage.dart';
|
||||||
|
import 'uploader/telemetry_uploader.dart';
|
||||||
|
import 'session/session_manager.dart';
|
||||||
|
import 'presence/heartbeat_service.dart';
|
||||||
|
import 'presence/presence_config.dart';
|
||||||
|
|
||||||
|
/// 遥测服务
|
||||||
|
/// 统一管理设备信息收集、事件记录、上传等功能
|
||||||
|
class TelemetryService {
|
||||||
|
static TelemetryService? _instance;
|
||||||
|
TelemetryService._();
|
||||||
|
|
||||||
|
factory TelemetryService() {
|
||||||
|
_instance ??= TelemetryService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
final _storage = TelemetryStorage();
|
||||||
|
late TelemetryUploader _uploader;
|
||||||
|
|
||||||
|
DeviceContext? _deviceContext;
|
||||||
|
|
||||||
|
/// 安装ID(设备唯一标识,首次安装生成,用于未登录用户的DAU去重)
|
||||||
|
late String _installId;
|
||||||
|
String get installId => _installId;
|
||||||
|
|
||||||
|
/// 用户ID(登录后设置)
|
||||||
|
String? _userId;
|
||||||
|
String? get userId => _userId;
|
||||||
|
|
||||||
|
/// API 基础地址
|
||||||
|
late String _apiBaseUrl;
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
|
||||||
|
/// 会话管理器
|
||||||
|
late SessionManager _sessionManager;
|
||||||
|
|
||||||
|
/// 心跳服务
|
||||||
|
late HeartbeatService _heartbeatService;
|
||||||
|
|
||||||
|
Timer? _configSyncTimer;
|
||||||
|
|
||||||
|
/// 初始化(在main.dart中调用)
|
||||||
|
Future<void> initialize({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
required BuildContext context,
|
||||||
|
String? userId,
|
||||||
|
Duration configSyncInterval = const Duration(hours: 1),
|
||||||
|
PresenceConfig? presenceConfig,
|
||||||
|
}) async {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
_apiBaseUrl = apiBaseUrl;
|
||||||
|
|
||||||
|
// 1. 初始化存储
|
||||||
|
await _storage.init();
|
||||||
|
|
||||||
|
// 2. 初始化或获取 installId
|
||||||
|
await _initInstallId();
|
||||||
|
|
||||||
|
// 3. 加载用户选择
|
||||||
|
await TelemetryConfig().loadUserOptIn();
|
||||||
|
|
||||||
|
// 4. 同步远程配置(首次)
|
||||||
|
await TelemetryConfig().syncFromRemote(apiBaseUrl);
|
||||||
|
|
||||||
|
// 5. 收集设备信息
|
||||||
|
_deviceContext = await DeviceInfoCollector().collect(context);
|
||||||
|
await _storage.saveDeviceContext(_deviceContext!.toJson());
|
||||||
|
|
||||||
|
// 6. 设置用户ID
|
||||||
|
_userId = userId;
|
||||||
|
|
||||||
|
// 7. 初始化上传器
|
||||||
|
_uploader = TelemetryUploader(
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
storage: _storage,
|
||||||
|
getAuthHeaders: _getAuthHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 8. 启动定时上传(如果启用)
|
||||||
|
if (TelemetryConfig().globalEnabled) {
|
||||||
|
_uploader.startPeriodicUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. 定期同步配置
|
||||||
|
_configSyncTimer = Timer.periodic(configSyncInterval, (_) async {
|
||||||
|
await TelemetryConfig().syncFromRemote(apiBaseUrl);
|
||||||
|
|
||||||
|
// 根据最新配置调整上传器状态
|
||||||
|
if (TelemetryConfig().globalEnabled) {
|
||||||
|
_uploader.startPeriodicUpload();
|
||||||
|
} else {
|
||||||
|
_uploader.stopPeriodicUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新心跳配置
|
||||||
|
if (TelemetryConfig().presenceConfig != null) {
|
||||||
|
_heartbeatService.updateConfig(TelemetryConfig().presenceConfig!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 10. 初始化会话管理器
|
||||||
|
_sessionManager = SessionManager();
|
||||||
|
_sessionManager.initialize(this);
|
||||||
|
|
||||||
|
// 11. 初始化心跳服务
|
||||||
|
_heartbeatService = HeartbeatService();
|
||||||
|
_heartbeatService.initialize(
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
config: presenceConfig ?? TelemetryConfig().presenceConfig,
|
||||||
|
getInstallId: () => _installId,
|
||||||
|
getUserId: () => _userId,
|
||||||
|
getAppVersion: () => _deviceContext?.appVersion ?? 'unknown',
|
||||||
|
getAuthHeaders: _getAuthHeaders,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('📊 TelemetryService initialized');
|
||||||
|
debugPrint(' InstallId: $_installId');
|
||||||
|
debugPrint(' UserId: $_userId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 初始化 installId
|
||||||
|
Future<void> _initInstallId() async {
|
||||||
|
final storedId = _storage.getInstallId();
|
||||||
|
|
||||||
|
if (storedId != null) {
|
||||||
|
_installId = storedId;
|
||||||
|
} else {
|
||||||
|
_installId = const Uuid().v4();
|
||||||
|
await _storage.saveInstallId(_installId);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('📊 [Telemetry] Install ID: $_installId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取认证头
|
||||||
|
Map<String, String> _getAuthHeaders() {
|
||||||
|
// TODO: 从 AuthService 获取 token
|
||||||
|
// final token = AuthService.instance.accessToken;
|
||||||
|
// if (token != null) {
|
||||||
|
// return {'Authorization': 'Bearer $token'};
|
||||||
|
// }
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录事件(核心方法)
|
||||||
|
void logEvent(
|
||||||
|
String eventName, {
|
||||||
|
EventType type = EventType.userAction,
|
||||||
|
EventLevel level = EventLevel.info,
|
||||||
|
Map<String, dynamic>? properties,
|
||||||
|
}) {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint('⚠️ TelemetryService not initialized, event ignored');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查配置:是否应该记录
|
||||||
|
if (!TelemetryConfig().shouldLog(type, eventName)) {
|
||||||
|
return; // 配置禁止记录
|
||||||
|
}
|
||||||
|
|
||||||
|
// 采样判断(错误和崩溃不采样)
|
||||||
|
if (_needsSampling(type)) {
|
||||||
|
if (Random().nextDouble() > TelemetryConfig().samplingRate) {
|
||||||
|
return; // 未被采样
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final event = TelemetryEvent(
|
||||||
|
eventId: const Uuid().v4(),
|
||||||
|
type: type,
|
||||||
|
level: level,
|
||||||
|
name: eventName,
|
||||||
|
properties: properties,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
userId: _userId,
|
||||||
|
sessionId: _sessionManager.currentSessionId,
|
||||||
|
installId: _installId,
|
||||||
|
deviceContextId: _deviceContext!.androidId,
|
||||||
|
);
|
||||||
|
|
||||||
|
_storage.enqueueEvent(event);
|
||||||
|
|
||||||
|
// 检查是否需要立即上传
|
||||||
|
_uploader.uploadIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否需要采样
|
||||||
|
bool _needsSampling(EventType type) {
|
||||||
|
// 错误、崩溃、会话事件 100% 上报,不采样
|
||||||
|
return type != EventType.error &&
|
||||||
|
type != EventType.crash &&
|
||||||
|
type != EventType.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录页面访问
|
||||||
|
void logPageView(String pageName, {Map<String, dynamic>? extra}) {
|
||||||
|
logEvent(
|
||||||
|
'page_view',
|
||||||
|
type: EventType.pageView,
|
||||||
|
properties: {'page': pageName, ...?extra},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录用户行为
|
||||||
|
void logUserAction(String action, {Map<String, dynamic>? properties}) {
|
||||||
|
logEvent(
|
||||||
|
action,
|
||||||
|
type: EventType.userAction,
|
||||||
|
properties: properties,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录错误
|
||||||
|
void logError(
|
||||||
|
String errorMessage, {
|
||||||
|
Object? error,
|
||||||
|
StackTrace? stackTrace,
|
||||||
|
Map<String, dynamic>? extra,
|
||||||
|
}) {
|
||||||
|
logEvent(
|
||||||
|
'error_occurred',
|
||||||
|
type: EventType.error,
|
||||||
|
level: EventLevel.error,
|
||||||
|
properties: {
|
||||||
|
'message': errorMessage,
|
||||||
|
'error': error?.toString(),
|
||||||
|
'stack_trace': stackTrace?.toString(),
|
||||||
|
...?extra,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录API调用
|
||||||
|
void logApiCall({
|
||||||
|
required String url,
|
||||||
|
required String method,
|
||||||
|
required int statusCode,
|
||||||
|
required int durationMs,
|
||||||
|
String? error,
|
||||||
|
}) {
|
||||||
|
logEvent(
|
||||||
|
'api_call',
|
||||||
|
type: EventType.apiCall,
|
||||||
|
level: error != null ? EventLevel.error : EventLevel.info,
|
||||||
|
properties: {
|
||||||
|
'url': url,
|
||||||
|
'method': method,
|
||||||
|
'status_code': statusCode,
|
||||||
|
'duration_ms': durationMs,
|
||||||
|
'error': error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 记录性能指标
|
||||||
|
void logPerformance(
|
||||||
|
String metricName, {
|
||||||
|
required int durationMs,
|
||||||
|
Map<String, dynamic>? extra,
|
||||||
|
}) {
|
||||||
|
logEvent(
|
||||||
|
metricName,
|
||||||
|
type: EventType.performance,
|
||||||
|
properties: {'duration_ms': durationMs, ...?extra},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置用户ID(登录后调用)
|
||||||
|
void setUserId(String? userId) {
|
||||||
|
_userId = userId;
|
||||||
|
debugPrint('📊 [Telemetry] User ID set: $userId');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除用户ID(退出登录时)
|
||||||
|
void clearUserId() {
|
||||||
|
_userId = null;
|
||||||
|
debugPrint('📊 [Telemetry] User ID cleared');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置用户是否同意数据收集
|
||||||
|
Future<void> setUserOptIn(bool optIn) async {
|
||||||
|
await TelemetryConfig().setUserOptIn(optIn);
|
||||||
|
|
||||||
|
if (!optIn) {
|
||||||
|
// 用户拒绝,停止上传并清空队列
|
||||||
|
_uploader.stopPeriodicUpload();
|
||||||
|
await _storage.clearEventQueue();
|
||||||
|
debugPrint('📊 Telemetry disabled by user');
|
||||||
|
} else {
|
||||||
|
// 用户同意,重新启动
|
||||||
|
if (TelemetryConfig().globalEnabled) {
|
||||||
|
_uploader.startPeriodicUpload();
|
||||||
|
}
|
||||||
|
debugPrint('📊 Telemetry enabled by user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 会话和在线状态相关方法 ==========
|
||||||
|
|
||||||
|
/// 获取当前会话 ID
|
||||||
|
String? get currentSessionId => _sessionManager.currentSessionId;
|
||||||
|
|
||||||
|
/// 获取会话时长(秒)
|
||||||
|
int get sessionDurationSeconds => _sessionManager.sessionDurationSeconds;
|
||||||
|
|
||||||
|
/// 心跳是否运行中
|
||||||
|
bool get isHeartbeatRunning => _heartbeatService.isRunning;
|
||||||
|
|
||||||
|
/// 心跳计数
|
||||||
|
int get heartbeatCount => _heartbeatService.heartbeatCount;
|
||||||
|
|
||||||
|
/// 更新心跳配置
|
||||||
|
void updatePresenceConfig(PresenceConfig config) {
|
||||||
|
_heartbeatService.updateConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取设备上下文
|
||||||
|
DeviceContext? get deviceContext => _deviceContext;
|
||||||
|
|
||||||
|
/// App退出前调用
|
||||||
|
Future<void> dispose() async {
|
||||||
|
_configSyncTimer?.cancel();
|
||||||
|
_sessionManager.dispose();
|
||||||
|
_heartbeatService.dispose();
|
||||||
|
await _uploader.forceUploadAll();
|
||||||
|
_isInitialized = false;
|
||||||
|
debugPrint('📊 TelemetryService disposed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置实例
|
||||||
|
static void reset() {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import '../storage/telemetry_storage.dart';
|
||||||
|
|
||||||
|
/// 遥测上传器
|
||||||
|
/// 负责批量上传事件到服务器
|
||||||
|
class TelemetryUploader {
|
||||||
|
final String apiBaseUrl;
|
||||||
|
final TelemetryStorage storage;
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
Timer? _uploadTimer;
|
||||||
|
bool _isUploading = false;
|
||||||
|
|
||||||
|
/// 获取认证头的回调
|
||||||
|
Map<String, String> Function()? getAuthHeaders;
|
||||||
|
|
||||||
|
TelemetryUploader({
|
||||||
|
required this.apiBaseUrl,
|
||||||
|
required this.storage,
|
||||||
|
this.getAuthHeaders,
|
||||||
|
}) : _dio = Dio(BaseOptions(
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// 启动定时上传(每30秒或累积20条)
|
||||||
|
void startPeriodicUpload({
|
||||||
|
Duration interval = const Duration(seconds: 30),
|
||||||
|
int batchSize = 20,
|
||||||
|
}) {
|
||||||
|
_uploadTimer?.cancel();
|
||||||
|
_uploadTimer = Timer.periodic(interval, (_) {
|
||||||
|
uploadIfNeeded(batchSize: batchSize);
|
||||||
|
});
|
||||||
|
debugPrint('📊 Telemetry uploader started (interval: ${interval.inSeconds}s)');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止定时上传
|
||||||
|
void stopPeriodicUpload() {
|
||||||
|
_uploadTimer?.cancel();
|
||||||
|
_uploadTimer = null;
|
||||||
|
debugPrint('📊 Telemetry uploader stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 条件上传(队列大于阈值才上传)
|
||||||
|
Future<void> uploadIfNeeded({int batchSize = 20}) async {
|
||||||
|
if (_isUploading) return;
|
||||||
|
|
||||||
|
final queueSize = storage.getQueueSize();
|
||||||
|
if (queueSize < 10) return; // 少于10条不上传,等待积累
|
||||||
|
|
||||||
|
await uploadBatch(batchSize: batchSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 立即上传一批
|
||||||
|
Future<bool> uploadBatch({int batchSize = 20}) async {
|
||||||
|
if (_isUploading) return false;
|
||||||
|
|
||||||
|
_isUploading = true;
|
||||||
|
try {
|
||||||
|
final events = storage.dequeueEvents(batchSize);
|
||||||
|
if (events.isEmpty) return true;
|
||||||
|
|
||||||
|
// 调用后端API
|
||||||
|
final response = await _dio.post(
|
||||||
|
'/api/v1/analytics/events',
|
||||||
|
data: {
|
||||||
|
'events': events.map((e) => e.toJson()).toList(),
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
headers: getAuthHeaders?.call(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
// 上传成功,删除本地记录
|
||||||
|
await storage.removeEvents(events.length);
|
||||||
|
debugPrint('✅ Uploaded ${events.length} telemetry events');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
debugPrint('❌ Upload failed: ${response.statusCode}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} on DioException catch (e) {
|
||||||
|
debugPrint('❌ Upload error (DioException): ${e.message}');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('❌ Upload error: $e');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
_isUploading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强制上传全部(app退出前调用)
|
||||||
|
Future<void> forceUploadAll() async {
|
||||||
|
stopPeriodicUpload();
|
||||||
|
|
||||||
|
int retries = 0;
|
||||||
|
while (storage.getQueueSize() > 0 && retries < 3) {
|
||||||
|
final success = await uploadBatch(batchSize: 50);
|
||||||
|
if (!success) {
|
||||||
|
retries++;
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storage.getQueueSize() > 0) {
|
||||||
|
debugPrint('⚠️ ${storage.getQueueSize()} events remaining in queue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
/// APK 安装器
|
||||||
|
/// 负责调用原生代码安装 APK 文件
|
||||||
|
class ApkInstaller {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.rwadurian.app/apk_installer');
|
||||||
|
|
||||||
|
/// 安装 APK
|
||||||
|
static Future<bool> installApk(File apkFile) async {
|
||||||
|
try {
|
||||||
|
if (!await apkFile.exists()) {
|
||||||
|
debugPrint('APK file not found');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Android 8.0+ 需要请求安装权限
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
final hasPermission = await _requestInstallPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
debugPrint('Install permission denied');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Installing APK: ${apkFile.path}');
|
||||||
|
final result = await _channel.invokeMethod('installApk', {
|
||||||
|
'apkPath': apkFile.path,
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('Installation triggered: $result');
|
||||||
|
return result == true;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('Install failed (PlatformException): ${e.message}');
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Install failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 请求安装权限(Android 8.0+)
|
||||||
|
static Future<bool> _requestInstallPermission() async {
|
||||||
|
if (await Permission.requestInstallPackages.isGranted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final status = await Permission.requestInstallPackages.request();
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有安装权限
|
||||||
|
static Future<bool> hasInstallPermission() async {
|
||||||
|
return await Permission.requestInstallPackages.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开应用设置页面(用于手动授权)
|
||||||
|
static Future<void> openAppSettings() async {
|
||||||
|
await openAppSettings();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
/// 应用市场检测器
|
||||||
|
/// 检测应用安装来源,决定升级策略
|
||||||
|
class AppMarketDetector {
|
||||||
|
static const MethodChannel _channel =
|
||||||
|
MethodChannel('com.rwadurian.app/app_market');
|
||||||
|
|
||||||
|
/// 常见应用市场包名
|
||||||
|
static const List<String> _marketPackages = [
|
||||||
|
'com.android.vending', // Google Play
|
||||||
|
'com.huawei.appmarket', // 华为应用市场
|
||||||
|
'com.xiaomi.market', // 小米应用商店
|
||||||
|
'com.oppo.market', // OPPO 软件商店
|
||||||
|
'com.bbk.appstore', // vivo 应用商店
|
||||||
|
'com.tencent.android.qqdownloader', // 应用宝
|
||||||
|
'com.qihoo.appstore', // 360 手机助手
|
||||||
|
'com.baidu.appsearch', // 百度手机助手
|
||||||
|
'com.wandoujia.phoenix2', // 豌豆荚
|
||||||
|
'com.dragon.android.pandaspace', // 91 助手
|
||||||
|
'com.sec.android.app.samsungapps', // 三星应用商店
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 获取安装来源
|
||||||
|
static Future<String?> getInstallerPackageName() async {
|
||||||
|
if (!Platform.isAndroid) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final installer =
|
||||||
|
await _channel.invokeMethod<String>('getInstallerPackageName');
|
||||||
|
return installer;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
debugPrint('Get installer failed (PlatformException): ${e.message}');
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Get installer failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否来自应用市场
|
||||||
|
static Future<bool> isFromAppMarket() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
|
||||||
|
if (installer == null || installer.isEmpty) {
|
||||||
|
return false; // 直接安装(如 adb install)
|
||||||
|
}
|
||||||
|
|
||||||
|
return _marketPackages.contains(installer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否来自 Google Play
|
||||||
|
static Future<bool> isFromGooglePlay() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
return installer == 'com.android.vending';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否来自国内应用市场
|
||||||
|
static Future<bool> isFromChineseMarket() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
|
||||||
|
if (installer == null || installer.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排除 Google Play
|
||||||
|
if (installer == 'com.android.vending') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _marketPackages.contains(installer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取安装来源名称(用于显示)
|
||||||
|
static Future<String> getInstallerName() async {
|
||||||
|
final installer = await getInstallerPackageName();
|
||||||
|
|
||||||
|
if (installer == null || installer.isEmpty) {
|
||||||
|
return '直接安装';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (installer) {
|
||||||
|
case 'com.android.vending':
|
||||||
|
return 'Google Play';
|
||||||
|
case 'com.huawei.appmarket':
|
||||||
|
return '华为应用市场';
|
||||||
|
case 'com.xiaomi.market':
|
||||||
|
return '小米应用商店';
|
||||||
|
case 'com.oppo.market':
|
||||||
|
return 'OPPO 软件商店';
|
||||||
|
case 'com.bbk.appstore':
|
||||||
|
return 'vivo 应用商店';
|
||||||
|
case 'com.tencent.android.qqdownloader':
|
||||||
|
return '应用宝';
|
||||||
|
case 'com.qihoo.appstore':
|
||||||
|
return '360 手机助手';
|
||||||
|
case 'com.baidu.appsearch':
|
||||||
|
return '百度手机助手';
|
||||||
|
case 'com.wandoujia.phoenix2':
|
||||||
|
return '豌豆荚';
|
||||||
|
case 'com.sec.android.app.samsungapps':
|
||||||
|
return '三星应用商店';
|
||||||
|
default:
|
||||||
|
return installer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开应用市场详情页
|
||||||
|
static Future<bool> openAppMarketDetail(String packageName) async {
|
||||||
|
final marketUri = Uri.parse('market://details?id=$packageName');
|
||||||
|
|
||||||
|
if (await canLaunchUrl(marketUri)) {
|
||||||
|
await launchUrl(marketUri, mode: LaunchMode.externalApplication);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
// 回退到 Google Play 网页版
|
||||||
|
final webUri =
|
||||||
|
Uri.parse('https://play.google.com/store/apps/details?id=$packageName');
|
||||||
|
if (await canLaunchUrl(webUri)) {
|
||||||
|
await launchUrl(webUri, mode: LaunchMode.externalApplication);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开 Google Play 详情页
|
||||||
|
static Future<bool> openGooglePlay(String packageName) async {
|
||||||
|
final uri = Uri.parse(
|
||||||
|
'https://play.google.com/store/apps/details?id=$packageName');
|
||||||
|
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:in_app_update/in_app_update.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Google Play 应用内更新类型
|
||||||
|
enum GooglePlayUpdateType {
|
||||||
|
/// 灵活更新:后台下载,可继续使用
|
||||||
|
flexible,
|
||||||
|
|
||||||
|
/// 强制更新:阻塞式,必须更新
|
||||||
|
immediate,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google Play 应用内更新器
|
||||||
|
class GooglePlayUpdater {
|
||||||
|
/// 检查是否有更新可用
|
||||||
|
static Future<bool> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||||
|
return updateInfo.updateAvailability ==
|
||||||
|
UpdateAvailability.updateAvailable;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Check update failed: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 灵活更新
|
||||||
|
/// 用户可以在后台下载,继续使用应用
|
||||||
|
static Future<void> performFlexibleUpdate() async {
|
||||||
|
try {
|
||||||
|
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||||
|
|
||||||
|
if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||||
|
if (updateInfo.flexibleUpdateAllowed) {
|
||||||
|
await InAppUpdate.startFlexibleUpdate();
|
||||||
|
|
||||||
|
InAppUpdate.completeFlexibleUpdate().then((_) {
|
||||||
|
debugPrint('Update completed, app will restart');
|
||||||
|
}).catchError((e) {
|
||||||
|
debugPrint('Update failed: $e');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
debugPrint('Flexible update not allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Flexible update error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 强制更新
|
||||||
|
/// 阻塞式更新,用户必须更新才能继续使用
|
||||||
|
static Future<void> performImmediateUpdate() async {
|
||||||
|
try {
|
||||||
|
final updateInfo = await InAppUpdate.checkForUpdate();
|
||||||
|
|
||||||
|
if (updateInfo.updateAvailability == UpdateAvailability.updateAvailable) {
|
||||||
|
if (updateInfo.immediateUpdateAllowed) {
|
||||||
|
await InAppUpdate.performImmediateUpdate();
|
||||||
|
} else {
|
||||||
|
debugPrint('Immediate update not allowed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Immediate update error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 智能更新策略
|
||||||
|
/// 根据版本差异决定更新方式
|
||||||
|
static Future<void> smartUpdate({
|
||||||
|
required int currentVersion,
|
||||||
|
required int latestVersion,
|
||||||
|
}) async {
|
||||||
|
final versionDiff = latestVersion - currentVersion;
|
||||||
|
|
||||||
|
if (versionDiff >= 10) {
|
||||||
|
// 版本差异大,强制更新
|
||||||
|
await performImmediateUpdate();
|
||||||
|
} else {
|
||||||
|
// 版本差异小,灵活更新
|
||||||
|
await performFlexibleUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,427 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import '../version_checker.dart';
|
||||||
|
import '../download_manager.dart';
|
||||||
|
import '../apk_installer.dart';
|
||||||
|
import '../app_market_detector.dart';
|
||||||
|
import '../models/version_info.dart';
|
||||||
|
|
||||||
|
/// 自建服务器更新器
|
||||||
|
/// 负责从自建服务器下载 APK 并安装
|
||||||
|
class SelfHostedUpdater {
|
||||||
|
final VersionChecker versionChecker;
|
||||||
|
final DownloadManager downloadManager;
|
||||||
|
|
||||||
|
SelfHostedUpdater({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
}) : versionChecker = VersionChecker(apiBaseUrl: apiBaseUrl),
|
||||||
|
downloadManager = DownloadManager();
|
||||||
|
|
||||||
|
/// 检查并提示更新
|
||||||
|
Future<void> checkAndPromptUpdate(BuildContext context) async {
|
||||||
|
final versionInfo = await versionChecker.checkForUpdate();
|
||||||
|
|
||||||
|
if (versionInfo == null) {
|
||||||
|
debugPrint('Already latest version');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// 检测安装来源
|
||||||
|
final isFromMarket = await AppMarketDetector.isFromAppMarket();
|
||||||
|
|
||||||
|
if (isFromMarket) {
|
||||||
|
_showMarketUpdateDialog(context, versionInfo);
|
||||||
|
} else {
|
||||||
|
_showSelfHostedUpdateDialog(context, versionInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默检查更新(不显示对话框)
|
||||||
|
Future<VersionInfo?> silentCheckUpdate() async {
|
||||||
|
return await versionChecker.checkForUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 应用市场更新提示
|
||||||
|
void _showMarketUpdateDialog(BuildContext context, VersionInfo versionInfo) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !versionInfo.forceUpdate,
|
||||||
|
builder: (context) => PopScope(
|
||||||
|
canPop: !versionInfo.forceUpdate,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'最新版本: ${versionInfo.version}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (versionInfo.updateLog != null) ...[
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
Text(
|
||||||
|
'更新内容:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Text(
|
||||||
|
versionInfo.updateLog!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
Text(
|
||||||
|
'检测到您的应用来自应用市场,建议前往应用市场更新以获得最佳体验。',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12.sp,
|
||||||
|
color: const Color(0xFFA8A29E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!versionInfo.forceUpdate)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'稍后',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF78716C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
final packageInfo = await versionChecker.getCurrentVersion();
|
||||||
|
await AppMarketDetector.openAppMarketDetail(
|
||||||
|
packageInfo.packageName);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFD4A84B),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'前往应用市场',
|
||||||
|
style: TextStyle(fontSize: 14.sp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 自建更新对话框
|
||||||
|
void _showSelfHostedUpdateDialog(
|
||||||
|
BuildContext context, VersionInfo versionInfo) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: !versionInfo.forceUpdate,
|
||||||
|
builder: (context) => PopScope(
|
||||||
|
canPop: !versionInfo.forceUpdate,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
versionInfo.forceUpdate ? '发现重要更新' : '发现新版本',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'最新版本: ${versionInfo.version}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Text(
|
||||||
|
'文件大小: ${versionInfo.fileSizeFriendly}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (versionInfo.updateLog != null) ...[
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
Text(
|
||||||
|
'更新内容:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Text(
|
||||||
|
versionInfo.updateLog!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (!versionInfo.forceUpdate)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'稍后',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF78716C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_startUpdate(context, versionInfo);
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFD4A84B),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'立即更新',
|
||||||
|
style: TextStyle(fontSize: 14.sp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开始下载并安装
|
||||||
|
Future<void> _startUpdate(
|
||||||
|
BuildContext context, VersionInfo versionInfo) async {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => _DownloadProgressDialog(
|
||||||
|
versionInfo: versionInfo,
|
||||||
|
downloadManager: downloadManager,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理下载的 APK
|
||||||
|
Future<void> cleanup() async {
|
||||||
|
await downloadManager.cleanupDownloadedApk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载进度对话框
|
||||||
|
class _DownloadProgressDialog extends StatefulWidget {
|
||||||
|
final VersionInfo versionInfo;
|
||||||
|
final DownloadManager downloadManager;
|
||||||
|
|
||||||
|
const _DownloadProgressDialog({
|
||||||
|
required this.versionInfo,
|
||||||
|
required this.downloadManager,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_DownloadProgressDialog> createState() =>
|
||||||
|
_DownloadProgressDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DownloadProgressDialogState extends State<_DownloadProgressDialog> {
|
||||||
|
double _progress = 0.0;
|
||||||
|
String _statusText = '准备下载...';
|
||||||
|
bool _isDownloading = true;
|
||||||
|
bool _hasError = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startDownload() async {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '正在下载...';
|
||||||
|
_isDownloading = true;
|
||||||
|
_hasError = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
final apkFile = await widget.downloadManager.downloadApk(
|
||||||
|
url: widget.versionInfo.downloadUrl,
|
||||||
|
sha256Expected: widget.versionInfo.sha256,
|
||||||
|
onProgress: (received, total) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_progress = received / total;
|
||||||
|
final receivedMB = (received / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
final totalMB = (total / 1024 / 1024).toStringAsFixed(1);
|
||||||
|
_statusText = '正在下载... $receivedMB MB / $totalMB MB';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (apkFile == null) {
|
||||||
|
setState(() {
|
||||||
|
_statusText = widget.downloadManager.status == DownloadStatus.cancelled
|
||||||
|
? '下载已取消'
|
||||||
|
: '下载失败,请稍后重试';
|
||||||
|
_isDownloading = false;
|
||||||
|
_hasError = true;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_statusText = '下载完成,准备安装...';
|
||||||
|
});
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(milliseconds: 500));
|
||||||
|
|
||||||
|
final installed = await ApkInstaller.installApk(apkFile);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_statusText = '安装失败,请手动安装';
|
||||||
|
_isDownloading = false;
|
||||||
|
_hasError = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelDownload() {
|
||||||
|
widget.downloadManager.cancelDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _retryDownload() {
|
||||||
|
widget.downloadManager.reset();
|
||||||
|
_startDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_isDownloading,
|
||||||
|
child: AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
'正在更新',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: _progress,
|
||||||
|
backgroundColor: const Color(0xFFE7E5E4),
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
_hasError ? const Color(0xFFEF4444) : const Color(0xFFD4A84B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
Text(
|
||||||
|
_statusText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: _hasError
|
||||||
|
? const Color(0xFFEF4444)
|
||||||
|
: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isDownloading) ...[
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Text(
|
||||||
|
'${(_progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFFD4A84B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_isDownloading)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _cancelDownload,
|
||||||
|
child: Text(
|
||||||
|
'取消',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF78716C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isDownloading && _hasError) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'关闭',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF78716C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _retryDownload,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFD4A84B),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'重试',
|
||||||
|
style: TextStyle(fontSize: 14.sp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 下载状态
|
||||||
|
enum DownloadStatus {
|
||||||
|
idle,
|
||||||
|
downloading,
|
||||||
|
verifying,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载进度回调
|
||||||
|
typedef DownloadProgressCallback = void Function(int received, int total);
|
||||||
|
|
||||||
|
/// 下载管理器
|
||||||
|
/// 负责下载 APK 文件并验证完整性
|
||||||
|
class DownloadManager {
|
||||||
|
final Dio _dio = Dio();
|
||||||
|
CancelToken? _cancelToken;
|
||||||
|
DownloadStatus _status = DownloadStatus.idle;
|
||||||
|
|
||||||
|
/// 当前下载状态
|
||||||
|
DownloadStatus get status => _status;
|
||||||
|
|
||||||
|
/// 下载 APK 文件
|
||||||
|
/// [url] 下载地址(必须是 HTTPS)
|
||||||
|
/// [sha256Expected] SHA-256 校验值
|
||||||
|
/// [onProgress] 下载进度回调 (已下载字节, 总字节)
|
||||||
|
Future<File?> downloadApk({
|
||||||
|
required String url,
|
||||||
|
required String sha256Expected,
|
||||||
|
DownloadProgressCallback? onProgress,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
// 强制 HTTPS
|
||||||
|
if (!url.startsWith('https://')) {
|
||||||
|
debugPrint('Download URL must use HTTPS');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_status = DownloadStatus.downloading;
|
||||||
|
_cancelToken = CancelToken();
|
||||||
|
|
||||||
|
// 使用应用专属目录(无需额外权限)
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final savePath = '${dir.path}/app_update.apk';
|
||||||
|
final file = File(savePath);
|
||||||
|
|
||||||
|
// 如果已存在则删除
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('Downloading APK to: $savePath');
|
||||||
|
|
||||||
|
await _dio.download(
|
||||||
|
url,
|
||||||
|
savePath,
|
||||||
|
cancelToken: _cancelToken,
|
||||||
|
onReceiveProgress: (received, total) {
|
||||||
|
if (total != -1) {
|
||||||
|
final progress = (received / total * 100).toStringAsFixed(0);
|
||||||
|
debugPrint('Download progress: $progress%');
|
||||||
|
onProgress?.call(received, total);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
receiveTimeout: const Duration(minutes: 10),
|
||||||
|
sendTimeout: const Duration(minutes: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('Download completed');
|
||||||
|
_status = DownloadStatus.verifying;
|
||||||
|
|
||||||
|
// 校验 SHA-256
|
||||||
|
final isValid = await _verifySha256(file, sha256Expected);
|
||||||
|
if (!isValid) {
|
||||||
|
debugPrint('SHA-256 verification failed');
|
||||||
|
await file.delete();
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('SHA-256 verified');
|
||||||
|
_status = DownloadStatus.completed;
|
||||||
|
return file;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.type == DioExceptionType.cancel) {
|
||||||
|
debugPrint('Download cancelled');
|
||||||
|
_status = DownloadStatus.cancelled;
|
||||||
|
} else {
|
||||||
|
debugPrint('Download failed: $e');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Download failed: $e');
|
||||||
|
_status = DownloadStatus.failed;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消下载
|
||||||
|
void cancelDownload() {
|
||||||
|
_cancelToken?.cancel('User cancelled download');
|
||||||
|
_status = DownloadStatus.cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置状态
|
||||||
|
void reset() {
|
||||||
|
_cancelToken = null;
|
||||||
|
_status = DownloadStatus.idle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验文件 SHA-256
|
||||||
|
Future<bool> _verifySha256(File file, String expectedSha256) async {
|
||||||
|
try {
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final digest = sha256.convert(bytes);
|
||||||
|
final actualSha256 = digest.toString();
|
||||||
|
|
||||||
|
debugPrint('Expected SHA-256: $expectedSha256');
|
||||||
|
debugPrint('Actual SHA-256: $actualSha256');
|
||||||
|
|
||||||
|
return actualSha256.toLowerCase() == expectedSha256.toLowerCase();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('SHA-256 verification error: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除已下载的 APK 文件
|
||||||
|
Future<void> cleanupDownloadedApk() async {
|
||||||
|
try {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File('${dir.path}/app_update.apk');
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
debugPrint('Cleaned up downloaded APK');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Cleanup failed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
/// 更新渠道类型
|
||||||
|
enum UpdateChannel {
|
||||||
|
/// Google Play 应用内更新
|
||||||
|
googlePlay,
|
||||||
|
|
||||||
|
/// 自建服务器 APK 升级
|
||||||
|
selfHosted,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新配置
|
||||||
|
class UpdateConfig {
|
||||||
|
/// 更新渠道
|
||||||
|
final UpdateChannel channel;
|
||||||
|
|
||||||
|
/// API 基础地址 (selfHosted 模式必需)
|
||||||
|
final String? apiBaseUrl;
|
||||||
|
|
||||||
|
/// 是否启用更新检测
|
||||||
|
final bool enabled;
|
||||||
|
|
||||||
|
/// 检查更新间隔(秒)
|
||||||
|
final int checkIntervalSeconds;
|
||||||
|
|
||||||
|
const UpdateConfig({
|
||||||
|
required this.channel,
|
||||||
|
this.apiBaseUrl,
|
||||||
|
this.enabled = true,
|
||||||
|
this.checkIntervalSeconds = 86400, // 默认24小时
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 默认配置 - 自建服务器模式
|
||||||
|
static UpdateConfig selfHosted({
|
||||||
|
required String apiBaseUrl,
|
||||||
|
bool enabled = true,
|
||||||
|
int checkIntervalSeconds = 86400,
|
||||||
|
}) {
|
||||||
|
return UpdateConfig(
|
||||||
|
channel: UpdateChannel.selfHosted,
|
||||||
|
apiBaseUrl: apiBaseUrl,
|
||||||
|
enabled: enabled,
|
||||||
|
checkIntervalSeconds: checkIntervalSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认配置 - Google Play 模式
|
||||||
|
static UpdateConfig googlePlay({
|
||||||
|
bool enabled = true,
|
||||||
|
int checkIntervalSeconds = 86400,
|
||||||
|
}) {
|
||||||
|
return UpdateConfig(
|
||||||
|
channel: UpdateChannel.googlePlay,
|
||||||
|
enabled: enabled,
|
||||||
|
checkIntervalSeconds: checkIntervalSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// 版本信息模型
|
||||||
|
class VersionInfo extends Equatable {
|
||||||
|
/// 版本号: "1.2.0"
|
||||||
|
final String version;
|
||||||
|
|
||||||
|
/// 版本代码: 120
|
||||||
|
final int versionCode;
|
||||||
|
|
||||||
|
/// APK 下载地址
|
||||||
|
final String downloadUrl;
|
||||||
|
|
||||||
|
/// 文件大小(字节)
|
||||||
|
final int fileSize;
|
||||||
|
|
||||||
|
/// 友好显示: "25.6 MB"
|
||||||
|
final String fileSizeFriendly;
|
||||||
|
|
||||||
|
/// SHA-256 校验
|
||||||
|
final String sha256;
|
||||||
|
|
||||||
|
/// 是否强制更新
|
||||||
|
final bool forceUpdate;
|
||||||
|
|
||||||
|
/// 更新日志
|
||||||
|
final String? updateLog;
|
||||||
|
|
||||||
|
/// 发布时间
|
||||||
|
final DateTime releaseDate;
|
||||||
|
|
||||||
|
const VersionInfo({
|
||||||
|
required this.version,
|
||||||
|
required this.versionCode,
|
||||||
|
required this.downloadUrl,
|
||||||
|
required this.fileSize,
|
||||||
|
required this.fileSizeFriendly,
|
||||||
|
required this.sha256,
|
||||||
|
this.forceUpdate = false,
|
||||||
|
this.updateLog,
|
||||||
|
required this.releaseDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VersionInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VersionInfo(
|
||||||
|
version: json['version'] as String,
|
||||||
|
versionCode: json['versionCode'] as int,
|
||||||
|
downloadUrl: json['downloadUrl'] as String,
|
||||||
|
fileSize: json['fileSize'] as int,
|
||||||
|
fileSizeFriendly: json['fileSizeFriendly'] as String,
|
||||||
|
sha256: json['sha256'] as String,
|
||||||
|
forceUpdate: json['forceUpdate'] as bool? ?? false,
|
||||||
|
updateLog: json['updateLog'] as String?,
|
||||||
|
releaseDate: DateTime.parse(json['releaseDate'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'version': version,
|
||||||
|
'versionCode': versionCode,
|
||||||
|
'downloadUrl': downloadUrl,
|
||||||
|
'fileSize': fileSize,
|
||||||
|
'fileSizeFriendly': fileSizeFriendly,
|
||||||
|
'sha256': sha256,
|
||||||
|
'forceUpdate': forceUpdate,
|
||||||
|
'updateLog': updateLog,
|
||||||
|
'releaseDate': releaseDate.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
version,
|
||||||
|
versionCode,
|
||||||
|
downloadUrl,
|
||||||
|
fileSize,
|
||||||
|
sha256,
|
||||||
|
forceUpdate,
|
||||||
|
releaseDate,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'channels/google_play_updater.dart';
|
||||||
|
import 'channels/self_hosted_updater.dart';
|
||||||
|
import 'models/update_config.dart';
|
||||||
|
import 'models/version_info.dart';
|
||||||
|
|
||||||
|
/// 统一升级服务
|
||||||
|
/// 根据配置自动选择合适的升级渠道
|
||||||
|
class UpdateService {
|
||||||
|
static UpdateService? _instance;
|
||||||
|
UpdateService._();
|
||||||
|
|
||||||
|
factory UpdateService() {
|
||||||
|
_instance ??= UpdateService._();
|
||||||
|
return _instance!;
|
||||||
|
}
|
||||||
|
|
||||||
|
late UpdateConfig _config;
|
||||||
|
SelfHostedUpdater? _selfHostedUpdater;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
|
||||||
|
/// 是否已初始化
|
||||||
|
bool get isInitialized => _isInitialized;
|
||||||
|
|
||||||
|
/// 当前配置
|
||||||
|
UpdateConfig get config => _config;
|
||||||
|
|
||||||
|
/// 初始化
|
||||||
|
void initialize(UpdateConfig config) {
|
||||||
|
_config = config;
|
||||||
|
|
||||||
|
if (config.channel == UpdateChannel.selfHosted) {
|
||||||
|
if (config.apiBaseUrl == null) {
|
||||||
|
throw ArgumentError('apiBaseUrl is required for self-hosted channel');
|
||||||
|
}
|
||||||
|
_selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
debugPrint('UpdateService initialized with channel: ${_config.channel}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新配置
|
||||||
|
void updateConfig(UpdateConfig config) {
|
||||||
|
_config = config;
|
||||||
|
|
||||||
|
if (config.channel == UpdateChannel.selfHosted && config.apiBaseUrl != null) {
|
||||||
|
_selfHostedUpdater = SelfHostedUpdater(apiBaseUrl: config.apiBaseUrl!);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint('UpdateService config updated');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查更新(自动显示对话框)
|
||||||
|
Future<void> checkForUpdate(BuildContext context) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint('UpdateService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_config.enabled) {
|
||||||
|
debugPrint('Update check is disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (_config.channel) {
|
||||||
|
case UpdateChannel.googlePlay:
|
||||||
|
await _checkGooglePlayUpdate(context);
|
||||||
|
break;
|
||||||
|
case UpdateChannel.selfHosted:
|
||||||
|
await _selfHostedUpdater!.checkAndPromptUpdate(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静默检查更新(不显示对话框)
|
||||||
|
Future<VersionInfo?> silentCheck() async {
|
||||||
|
if (!_isInitialized || !_config.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_config.channel == UpdateChannel.selfHosted) {
|
||||||
|
return await _selfHostedUpdater?.silentCheckUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Google Play 没有静默检查,返回 null
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Google Play 更新检查
|
||||||
|
Future<void> _checkGooglePlayUpdate(BuildContext context) async {
|
||||||
|
final hasUpdate = await GooglePlayUpdater.checkForUpdate();
|
||||||
|
|
||||||
|
if (!hasUpdate) {
|
||||||
|
debugPrint('No update available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(
|
||||||
|
'发现新版本',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF292524),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
content: Text(
|
||||||
|
'有新版本可用,是否立即更新?',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF57534E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(
|
||||||
|
'稍后',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
color: const Color(0xFF78716C),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
GooglePlayUpdater.performFlexibleUpdate();
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFFD4A84B),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'更新',
|
||||||
|
style: TextStyle(fontSize: 14.sp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 手动检查更新(带加载提示)
|
||||||
|
Future<void> manualCheckUpdate(BuildContext context) async {
|
||||||
|
if (!_isInitialized) {
|
||||||
|
debugPrint('UpdateService not initialized');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示加载中
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (context) => const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFFD4A84B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// 检查是否有更新
|
||||||
|
final versionInfo = await silentCheck();
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (versionInfo == null) {
|
||||||
|
// 没有更新
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'当前已是最新版本',
|
||||||
|
style: TextStyle(fontSize: 14.sp),
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFF22C55E),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 有更新,显示对话框
|
||||||
|
await checkForUpdate(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行强制更新
|
||||||
|
Future<void> performForceUpdate(BuildContext context) async {
|
||||||
|
if (!_isInitialized) return;
|
||||||
|
|
||||||
|
switch (_config.channel) {
|
||||||
|
case UpdateChannel.googlePlay:
|
||||||
|
await GooglePlayUpdater.performImmediateUpdate();
|
||||||
|
break;
|
||||||
|
case UpdateChannel.selfHosted:
|
||||||
|
await _selfHostedUpdater?.checkAndPromptUpdate(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清理缓存的更新文件
|
||||||
|
Future<void> cleanup() async {
|
||||||
|
await _selfHostedUpdater?.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重置实例
|
||||||
|
static void reset() {
|
||||||
|
_instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'models/version_info.dart';
|
||||||
|
|
||||||
|
/// 版本检测器
|
||||||
|
/// 负责从服务器获取最新版本信息并与当前版本比较
|
||||||
|
class VersionChecker {
|
||||||
|
final String apiBaseUrl;
|
||||||
|
final Dio _dio;
|
||||||
|
|
||||||
|
VersionChecker({required this.apiBaseUrl})
|
||||||
|
: _dio = Dio(BaseOptions(
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
connectTimeout: const Duration(seconds: 10),
|
||||||
|
receiveTimeout: const Duration(seconds: 10),
|
||||||
|
));
|
||||||
|
|
||||||
|
/// 获取当前版本信息
|
||||||
|
Future<PackageInfo> getCurrentVersion() async {
|
||||||
|
return await PackageInfo.fromPlatform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从服务器获取最新版本信息
|
||||||
|
Future<VersionInfo?> fetchLatestVersion() async {
|
||||||
|
try {
|
||||||
|
final currentInfo = await getCurrentVersion();
|
||||||
|
|
||||||
|
final response = await _dio.get(
|
||||||
|
'/api/app/version/check',
|
||||||
|
queryParameters: {
|
||||||
|
'platform': 'android',
|
||||||
|
'current_version': currentInfo.version,
|
||||||
|
'current_version_code': currentInfo.buildNumber,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200 && response.data != null) {
|
||||||
|
// 检查是否需要更新
|
||||||
|
final needUpdate = response.data['needUpdate'] as bool? ?? true;
|
||||||
|
if (!needUpdate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return VersionInfo.fromJson(response.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Fetch version failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 检查是否有新版本
|
||||||
|
Future<VersionInfo?> checkForUpdate() async {
|
||||||
|
try {
|
||||||
|
final currentInfo = await getCurrentVersion();
|
||||||
|
final latestInfo = await fetchLatestVersion();
|
||||||
|
|
||||||
|
if (latestInfo == null) return null;
|
||||||
|
|
||||||
|
final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0;
|
||||||
|
if (latestInfo.versionCode > currentCode) {
|
||||||
|
return latestInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Check update failed: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 是否需要强制更新
|
||||||
|
Future<bool> needForceUpdate() async {
|
||||||
|
final latestInfo = await checkForUpdate();
|
||||||
|
return latestInfo?.forceUpdate ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取版本差异
|
||||||
|
Future<int> getVersionDiff() async {
|
||||||
|
try {
|
||||||
|
final currentInfo = await getCurrentVersion();
|
||||||
|
final latestInfo = await fetchLatestVersion();
|
||||||
|
|
||||||
|
if (latestInfo == null) return 0;
|
||||||
|
|
||||||
|
final currentCode = int.tryParse(currentInfo.buildNumber) ?? 0;
|
||||||
|
return latestInfo.versionCode - currentCode;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Get version diff failed: $e');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/constants/app_constants.dart';
|
import '../../../../core/constants/app_constants.dart';
|
||||||
import '../../../../routes/route_paths.dart';
|
import '../../../../routes/route_paths.dart';
|
||||||
|
import '../../../../bootstrap.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 开屏页面 - 应用启动时显示的第一个页面
|
/// 开屏页面 - 应用启动时显示的第一个页面
|
||||||
|
|
@ -23,6 +24,9 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
|
|
||||||
/// 初始化应用并检查认证状态
|
/// 初始化应用并检查认证状态
|
||||||
Future<void> _initializeApp() async {
|
Future<void> _initializeApp() async {
|
||||||
|
// 初始化遥测服务(需要 BuildContext)
|
||||||
|
await initializeTelemetry(context);
|
||||||
|
|
||||||
// 等待开屏动画展示
|
// 等待开屏动画展示
|
||||||
await Future.delayed(AppConstants.splashDuration);
|
await Future.delayed(AppConstants.splashDuration);
|
||||||
|
|
||||||
|
|
@ -33,15 +37,26 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
||||||
|
|
||||||
final authState = ref.read(authProvider);
|
final authState = ref.read(authProvider);
|
||||||
|
|
||||||
|
// 根据认证状态决定跳转目标
|
||||||
|
String targetRoute;
|
||||||
if (authState.isWalletCreated) {
|
if (authState.isWalletCreated) {
|
||||||
// 已创建钱包,进入主页面(龙虎榜)
|
// 已创建钱包,进入主页面(龙虎榜)
|
||||||
context.go(RoutePaths.ranking);
|
targetRoute = RoutePaths.ranking;
|
||||||
} else if (authState.isFirstLaunch || !authState.hasSeenGuide) {
|
} else if (authState.isFirstLaunch || !authState.hasSeenGuide) {
|
||||||
// 首次打开或未看过向导,进入向导页
|
// 首次打开或未看过向导,进入向导页
|
||||||
context.go(RoutePaths.guide);
|
targetRoute = RoutePaths.guide;
|
||||||
} else {
|
} else {
|
||||||
// 已看过向导但未创建钱包,直接进入创建账户页面
|
// 已看过向导但未创建钱包,直接进入创建账户页面
|
||||||
context.go(RoutePaths.onboarding);
|
targetRoute = RoutePaths.onboarding;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳转到目标页面
|
||||||
|
context.go(targetRoute);
|
||||||
|
|
||||||
|
// 延迟检查应用更新(跳转后执行,避免阻塞启动)
|
||||||
|
if (targetRoute == RoutePaths.ranking) {
|
||||||
|
// 只在进入主页面时检查更新
|
||||||
|
checkForAppUpdate(context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.5+1"
|
version: "0.3.5+1"
|
||||||
crypto:
|
crypto:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: crypto
|
name: crypto
|
||||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||||
|
|
@ -281,6 +281,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.11"
|
version: "0.7.11"
|
||||||
|
device_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: device_info_plus
|
||||||
|
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "11.5.0"
|
||||||
|
device_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: device_info_plus_platform_interface
|
||||||
|
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.3"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -693,6 +709,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.2"
|
version: "0.2.2"
|
||||||
|
in_app_update:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: in_app_update
|
||||||
|
sha256: "9924a3efe592e1c0ec89dda3683b3cfec3d4cd02d908e6de00c24b759038ddb1"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.2.5"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -901,6 +925,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
package_info_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: package_info_plus
|
||||||
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "8.3.1"
|
||||||
|
package_info_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: package_info_plus_platform_interface
|
||||||
|
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.2.1"
|
||||||
path:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -918,7 +958,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
path_provider:
|
path_provider:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: path_provider
|
name: path_provider
|
||||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||||
|
|
@ -1153,18 +1193,18 @@ packages:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: share_plus
|
name: share_plus
|
||||||
sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51
|
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.3"
|
version: "10.1.4"
|
||||||
share_plus_platform_interface:
|
share_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: share_plus_platform_interface
|
name: share_plus_platform_interface
|
||||||
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
|
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.4.0"
|
version: "5.0.2"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1275,7 +1315,7 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.10.1"
|
version: "1.10.1"
|
||||||
sqflite:
|
sqflite:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: sqflite
|
name: sqflite
|
||||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||||
|
|
@ -1526,10 +1566,10 @@ packages:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web
|
name: web
|
||||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.5.1"
|
version: "1.1.1"
|
||||||
web3dart:
|
web3dart:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
@ -1562,6 +1602,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.15.0"
|
version: "5.15.0"
|
||||||
|
win32_registry:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: win32_registry
|
||||||
|
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -52,11 +52,21 @@ dependencies:
|
||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
permission_handler: ^11.3.1
|
permission_handler: ^11.3.1
|
||||||
url_launcher: ^6.2.6
|
url_launcher: ^6.2.6
|
||||||
share_plus: ^8.0.3
|
share_plus: ^10.0.0
|
||||||
|
|
||||||
# 生物识别
|
# 生物识别
|
||||||
local_auth: ^2.2.0
|
local_auth: ^2.2.0
|
||||||
|
|
||||||
|
# APK升级
|
||||||
|
package_info_plus: ^8.0.0
|
||||||
|
in_app_update: ^4.2.2
|
||||||
|
path_provider: ^2.1.0
|
||||||
|
crypto: ^3.0.3
|
||||||
|
|
||||||
|
# 设备信息收集与遥测
|
||||||
|
device_info_plus: ^11.0.0
|
||||||
|
sqflite: ^2.3.0
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue