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">
|
||||
<!-- 网络权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- APK安装权限 (Android 8.0+) -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:label="榴莲皇后"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -30,6 +35,17 @@
|
|||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
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>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
|
|
|||
|
|
@ -1,5 +1,85 @@
|
|||
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.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/di/injection_container.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 {
|
||||
// Ensure Flutter bindings are initialized
|
||||
|
|
@ -33,12 +39,30 @@ Future<void> bootstrap(FutureOr<Widget> Function() builder) async {
|
|||
// Initialize LocalStorage
|
||||
final localStorage = await LocalStorage.init();
|
||||
|
||||
// Initialize UpdateService (自建服务器模式)
|
||||
UpdateService().initialize(
|
||||
UpdateConfig.selfHosted(
|
||||
apiBaseUrl: _apiBaseUrl,
|
||||
enabled: true,
|
||||
checkIntervalSeconds: 86400, // 24小时
|
||||
),
|
||||
);
|
||||
|
||||
// Create provider container with initialized dependencies
|
||||
final container = createProviderContainer(localStorage);
|
||||
|
||||
// Run app with error handling
|
||||
FlutterError.onError = (details) {
|
||||
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(
|
||||
|
|
@ -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 '../../../../core/constants/app_constants.dart';
|
||||
import '../../../../routes/route_paths.dart';
|
||||
import '../../../../bootstrap.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// 开屏页面 - 应用启动时显示的第一个页面
|
||||
|
|
@ -23,6 +24,9 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
|||
|
||||
/// 初始化应用并检查认证状态
|
||||
Future<void> _initializeApp() async {
|
||||
// 初始化遥测服务(需要 BuildContext)
|
||||
await initializeTelemetry(context);
|
||||
|
||||
// 等待开屏动画展示
|
||||
await Future.delayed(AppConstants.splashDuration);
|
||||
|
||||
|
|
@ -33,15 +37,26 @@ class _SplashPageState extends ConsumerState<SplashPage> {
|
|||
|
||||
final authState = ref.read(authProvider);
|
||||
|
||||
// 根据认证状态决定跳转目标
|
||||
String targetRoute;
|
||||
if (authState.isWalletCreated) {
|
||||
// 已创建钱包,进入主页面(龙虎榜)
|
||||
context.go(RoutePaths.ranking);
|
||||
targetRoute = RoutePaths.ranking;
|
||||
} else if (authState.isFirstLaunch || !authState.hasSeenGuide) {
|
||||
// 首次打开或未看过向导,进入向导页
|
||||
context.go(RoutePaths.guide);
|
||||
targetRoute = RoutePaths.guide;
|
||||
} else {
|
||||
// 已看过向导但未创建钱包,直接进入创建账户页面
|
||||
context.go(RoutePaths.onboarding);
|
||||
targetRoute = RoutePaths.onboarding;
|
||||
}
|
||||
|
||||
// 跳转到目标页面
|
||||
context.go(targetRoute);
|
||||
|
||||
// 延迟检查应用更新(跳转后执行,避免阻塞启动)
|
||||
if (targetRoute == RoutePaths.ranking) {
|
||||
// 只在进入主页面时检查更新
|
||||
checkForAppUpdate(context);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ packages:
|
|||
source: hosted
|
||||
version: "0.3.5+1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
|
@ -281,6 +281,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -693,6 +709,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -901,6 +925,22 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -918,7 +958,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
|
|
@ -1153,18 +1193,18 @@ packages:
|
|||
dependency: "direct main"
|
||||
description:
|
||||
name: share_plus
|
||||
sha256: fb5319f3aab4c5dda5ebb92dca978179ba21f8c783ee4380910ef4c1c6824f51
|
||||
sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.0.3"
|
||||
version: "10.1.4"
|
||||
share_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: share_plus_platform_interface
|
||||
sha256: "251eb156a8b5fa9ce033747d73535bf53911071f8d3b6f4f0b578505ce0d4496"
|
||||
sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.0"
|
||||
version: "5.0.2"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1275,7 +1315,7 @@ packages:
|
|||
source: hosted
|
||||
version: "1.10.1"
|
||||
sqflite:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite
|
||||
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
|
||||
|
|
@ -1526,10 +1566,10 @@ packages:
|
|||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27"
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.1"
|
||||
version: "1.1.1"
|
||||
web3dart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
@ -1562,6 +1602,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -52,11 +52,21 @@ dependencies:
|
|||
image_picker: ^1.0.7
|
||||
permission_handler: ^11.3.1
|
||||
url_launcher: ^6.2.6
|
||||
share_plus: ^8.0.3
|
||||
share_plus: ^10.0.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
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
Loading…
Reference in New Issue