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:
hailin 2025-11-27 02:44:01 -08:00
parent 429173464c
commit be4ef7d1aa
29 changed files with 5009 additions and 14 deletions

View File

@ -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

View File

@ -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)
}
}

View File

@ -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>

View File

@ -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

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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,
];
}

View File

@ -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;
}
}

View File

@ -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,
];
}

View File

@ -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();
}
}

View File

@ -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,
);
}
}

View File

@ -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,
}

View File

@ -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;
}
}

View File

@ -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));
}
}

View File

@ -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;
/// IDDAU去重
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;
}
}

View File

@ -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),
));
/// (3020)
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');
}
}
}

View File

@ -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();
}
}

View File

@ -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;
}
}

View File

@ -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();
}
}
}

View File

@ -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),
),
),
],
],
),
);
}
}

View File

@ -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');
}
}
}

View File

@ -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,
);
}
}

View File

@ -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,
];
}

View File

@ -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;
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}
}

View File

@ -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:

View File

@ -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: