Commit 412d71340899e3c34deef5e4149da70906505425

Authored by 权海
1 parent 3a10895f

feat(ui):增加上传日志

... ... @@ -21,6 +21,8 @@ class DeveloperOptionsItem {
enum DeveloperOptionsAction {
shareHealthRawDataDatabase,
shareHealthRawUploadLog,
clearHealthRawDataDatabaseAndUploadLog,
}
class DeveloperOptionsController extends GetxController {
... ... @@ -51,6 +53,14 @@ class DeveloperOptionsController extends GetxController {
title: 'Share HealthRawData Database',
action: DeveloperOptionsAction.shareHealthRawDataDatabase,
),
DeveloperOptionsItem(
title: '本地上传日志',
action: DeveloperOptionsAction.shareHealthRawUploadLog,
),
DeveloperOptionsItem(
title: '清除本地数据库、上传日志',
action: DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog,
),
];
@override
... ... @@ -70,6 +80,12 @@ class DeveloperOptionsController extends GetxController {
case DeveloperOptionsAction.shareHealthRawDataDatabase:
await shareHealthRawDataDatabase();
break;
case DeveloperOptionsAction.shareHealthRawUploadLog:
await shareHealthRawUploadLog();
break;
case DeveloperOptionsAction.clearHealthRawDataDatabaseAndUploadLog:
await clearHealthRawDataDatabaseAndUploadLog();
break;
case null:
break;
}
... ... @@ -85,6 +101,30 @@ class DeveloperOptionsController extends GetxController {
}
}
Future<void> shareHealthRawUploadLog() async {
try {
final text = await _healthRawDataCoreService.readUploadApiLogText();
await _platformHostApi.shareText(text);
} catch (error, stackTrace) {
AppLogger.e('Share HealthRawData upload log failed', error, stackTrace);
Get.snackbar('Share failed', error.toString());
}
}
Future<void> clearHealthRawDataDatabaseAndUploadLog() async {
try {
await _healthRawDataCoreService.clearLocalDatabaseAndUploadLog();
Get.snackbar('已清除', '本地数据库和上传日志已清除');
} catch (error, stackTrace) {
AppLogger.e(
'Clear HealthRawData database and upload log failed',
error,
stackTrace,
);
Get.snackbar('Clear failed', error.toString());
}
}
Future<void> saveAccessToken() async {
final token = tokenController.text.trim();
await _userPreferencesStorage.updateAccessToken(token);
... ...
... ... @@ -51,6 +51,8 @@ class HealthRawDataCoreService {
bool _isUploadingDailyStressResults = false;
bool _isUploadingSleepResults = false;
bool get _isDebug => _environmentConfig?.isDebug ?? false;
int get _userId {
final userId = _userIdProvider?.call() ?? 0;
if (userId <= 0) {
... ... @@ -132,6 +134,30 @@ class HealthRawDataCoreService {
return bytes;
}
Future<String> readUploadApiLogText() async {
final file = await _uploadApiLogFile();
if (!await file.exists()) {
return _emptyUploadApiLogText;
}
final text = await file.readAsString();
if (text.trim().isEmpty) {
return _emptyUploadApiLogText;
}
return text;
}
Future<void> clearLocalDatabaseAndUploadLog() async {
await _localStore.clearTables(_userId);
await clearUploadApiLog();
}
Future<void> clearUploadApiLog() async {
final file = await _uploadApiLogFile();
if (await file.exists()) {
await file.delete();
}
}
Future<HealthRawStressCalculationResult> startCoreCaculate({
int? endTime,
int readChunkDays = defaultReadChunkDays,
... ... @@ -533,8 +559,11 @@ class HealthRawDataCoreService {
}
_isUploadingHrvResults = true;
try {
final uploadedUntil = await _rawDataApi.performHRVDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
final uploadedUntil = await _callUploadApi<int>(
apiName: 'performHRVDataUpload',
body: () async => _rawDataApi.performHRVDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
),
);
if (uploadedUntil > 0) {
await _localStore.markHrvStressUploadedUntil(
... ... @@ -558,8 +587,11 @@ class HealthRawDataCoreService {
}
_isUploadingRealtimeStressResults = true;
try {
final uploadedUntil = await _rawDataApi.performHRDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
final uploadedUntil = await _callUploadApi<int>(
apiName: 'performHRDataUpload',
body: () async => _rawDataApi.performHRDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
),
);
if (uploadedUntil > 0) {
await _localStore.markRealtimeStressUploadedUntil(
... ... @@ -583,9 +615,11 @@ class HealthRawDataCoreService {
}
_isUploadingDailyStressResults = true;
try {
final uploadedUntil =
await _rawDataApi.performAvgRealtimeStressDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
final uploadedUntil = await _callUploadApi<int>(
apiName: 'performAvgRealtimeStressDataUpload',
body: () async => _rawDataApi.performAvgRealtimeStressDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
),
);
if (uploadedUntil > 0) {
await _localStore.markDailyStressUploadedUntil(
... ... @@ -609,8 +643,11 @@ class HealthRawDataCoreService {
}
_isUploadingSleepResults = true;
try {
final uploadedUntil = await _rawDataApi.performSleepAnalysisDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
final uploadedUntil = await _callUploadApi<int>(
apiName: 'performSleepAnalysisDataUpload',
body: () async => _rawDataApi.performSleepAnalysisDataUpload(
sqliteFilePath: await _localStore.dbPath(_userId),
),
);
if (uploadedUntil > 0) {
await _localStore.markSleepResultsUploadedUntil(
... ... @@ -625,6 +662,54 @@ class HealthRawDataCoreService {
}
}
Future<T> _callUploadApi<T>({
required String apiName,
required Future<T> Function() body,
}) async {
final calledAt = DateTime.now();
try {
final result = await body();
await _appendUploadApiLog(
calledAt: calledAt,
apiName: apiName,
result: result,
);
return result;
} catch (error) {
await _appendUploadApiLog(
calledAt: calledAt,
apiName: apiName,
result: 'ERROR: $error',
);
rethrow;
}
}
Future<void> _appendUploadApiLog({
required DateTime calledAt,
required String apiName,
required Object? result,
}) async {
if (!_isDebug) return;
final file = await _uploadApiLogFile();
await file.parent.create(recursive: true);
final line =
'${_formatDateTimeMilliseconds(calledAt)} $apiName result=$result\n';
await file.writeAsString(line, mode: FileMode.append, flush: true);
}
Future<File> _uploadApiLogFile() async {
final dir = await getApplicationDocumentsDirectory();
return File('${dir.path}/health_raw_upload_debug.log');
}
String get _emptyUploadApiLogText {
if (_isDebug) {
return '暂无本地上传日志';
}
return '当前非 Debug 模式,未记录本地上传日志';
}
HealthRawStressCalculationResult calculate({
required List<HealthKitRawDataPoint> hrvPoints,
required List<HealthKitRawDataPoint> heartRatePoints,
... ... @@ -1432,6 +1517,20 @@ class HealthRawStressLocalStore {
);
}
Future<void> clearTables(int userId) async {
final db = await _database(userId);
await db.transaction((txn) async {
for (final table in const [
hrvResultsTable,
realtimeStressResultsTable,
dailyStressResultsTable,
sleepResultsTable,
]) {
await txn.delete(table);
}
});
}
Future<void> ensureReadable(int userId) async {
final db = await _database(userId);
await db.query(
... ... @@ -1944,6 +2043,11 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
return rows.isNotEmpty;
}
int _todayDateKey() {
final now = DateTime.now();
return now.year * 10000 + now.month * 100 + now.day;
}
Map<String, Object?> _hrvRow(HealthRawHrvStressPoint point) {
return <String, Object?>{
'raw_end_time': point.rawEndTime,
... ... @@ -2068,6 +2172,10 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
DatabaseExecutor db,
Map<String, Object?> row,
) async {
final isToday = row['date'] == _todayDateKey();
if (isToday) {
row['uploaded'] = 0;
}
final existing = await db.query(
dailyStressResultsTable,
where: 'date = ?',
... ... @@ -2092,7 +2200,7 @@ CREATE TABLE IF NOT EXISTS $sleepResultsTable (
'stress_score': row['stress_score'],
'state': row['state'],
'data_time': row['data_time'],
'uploaded': valueChanged ? 0 : existingRow['uploaded'],
'uploaded': isToday ? 0 : (valueChanged ? 0 : existingRow['uploaded']),
},
where: 'date = ?',
whereArgs: [row['date']],
... ...
... ... @@ -240,6 +240,106 @@ void main() {
expect(daily.single.uploaded, isFalse);
});
test('daily stress marks uploaded after upload and resets today on calculate',
() async {
final now = DateTime.now();
final todayDate = now.year * 10000 + now.month * 100 + now.day;
final nowSeconds = now.millisecondsSinceEpoch ~/ 1000;
final store = _MemoryHealthRawStressLocalStore();
store.insertDailyStress(
HealthRawDailyStressPoint(
userId: 42,
date: todayDate,
stressValue: 40,
stressScore: 40,
state: HealthRawStressState.normal,
dataTime: nowSeconds,
),
);
await store.markDailyStressUploadedUntil(userId: 42, date: todayDate);
var daily = await store.queryDailyStressPoints(
userId: 42,
startDate: todayDate,
endDate: todayDate,
);
expect(daily.single.uploaded, isTrue);
await store.upsertDailyStressPoints(
userId: 42,
points: [
HealthRawDailyStressPoint(
userId: 42,
date: todayDate,
stressValue: 40,
stressScore: 40,
state: HealthRawStressState.normal,
dataTime: nowSeconds + 1,
),
],
);
daily = await store.queryDailyStressPoints(
userId: 42,
startDate: todayDate,
endDate: todayDate,
);
expect(daily.single.uploaded, isFalse);
});
test('daily stress recalculates today before native upload', () async {
final operationLog = <String>[];
final now = DateTime.now();
final todayStart = DateTime(now.year, now.month, now.day);
final todayDate =
todayStart.year * 10000 + todayStart.month * 100 + todayStart.day;
final startSeconds = todayStart.millisecondsSinceEpoch ~/ 1000;
final api = _FakeHealthKitRawDataHostApi(operationLog: operationLog)
..dailyStressUploadUntil = todayDate;
final store = _MemoryHealthRawStressLocalStore(operationLog: operationLog);
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: [
_realtimeStressPoint(startSeconds + 60),
_realtimeStressPoint(startSeconds + 120),
_realtimeStressPoint(startSeconds + 180),
],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
store.insertDailyStress(
HealthRawDailyStressPoint(
userId: 42,
date: todayDate,
stressValue: 30,
stressScore: 30,
state: HealthRawStressState.normal,
dataTime: startSeconds,
uploaded: true,
),
);
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
);
await service.startCoreCaculate(
endTime: startSeconds + 240,
readChunkDays: 1,
);
expect(
operationLog.indexOf('upsertDailyStressPoints'),
lessThan(operationLog.indexOf('performAvgRealtimeStressDataUpload')),
);
expect(api.dailyStressUploadCallCount, 1);
final rows = await store.debugQueryAllRows(42);
expect(rows.dailyStressRows.single['uploaded'], 1);
});
test('same calculated rows keep uploaded state', () async {
final store = _MemoryHealthRawStressLocalStore();
const uploadedDaily = HealthRawDailyStressPoint(
... ... @@ -414,6 +514,77 @@ void main() {
expect(rows.sleepRows.single['uploaded'], 1);
});
test(
'startCoreCaculate uploads pending rows even when no new data calculates',
() async {
const base = 1800000000;
final oldDate = DateTime.fromMillisecondsSinceEpoch(base * 1000);
final oldDateKey = oldDate.year * 10000 + oldDate.month * 100 + oldDate.day;
final api = _FakeHealthKitRawDataHostApi()
..hrvUploadUntil = base + 10
..hrUploadUntil = base + 20
..dailyStressUploadUntil = oldDateKey
..sleepUploadUntil = base + 30;
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: [_hrvStressPoint(base + 10)],
realtimeStressPoints: [_realtimeStressPoint(base + 20)],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
store.insertDailyStress(
HealthRawDailyStressPoint(
userId: 42,
date: oldDateKey,
stressValue: 40,
stressScore: 40,
state: HealthRawStressState.normal,
dataTime: base + 20,
),
);
await store.upsertSleepResults(
userId: 42,
results: const [
HealthRawSleepResult(
userId: 42,
date: base + 30,
startDate: base,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 500,
awakMinutes: 20,
sleepMinutes: 480,
),
],
);
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
);
final result = await service.startCoreCaculate(
endTime: base + 40,
readChunkDays: 1,
);
final rows = await store.debugQueryAllRows(42);
expect(result.hrvStressPoints, isEmpty);
expect(result.realtimeStressPoints, isEmpty);
expect(result.sleepResults, isEmpty);
expect(api.hrvUploadCallCount, 1);
expect(api.hrUploadCallCount, 1);
expect(api.dailyStressUploadCallCount, 1);
expect(api.sleepUploadCallCount, 1);
expect(rows.hrvRows.single['uploaded'], 1);
expect(rows.realtimeRows.single['uploaded'], 1);
expect(rows.dailyStressRows.single['uploaded'], 1);
expect(rows.sleepRows.single['uploaded'], 1);
});
test('local data source returns earliest local hr start time', () async {
const base = 1800000000;
final api = _FakeHealthKitRawDataHostApi();
... ... @@ -472,14 +643,40 @@ HealthRawRealtimeStressPoint _realtimeStressPoint(int time) {
);
}
HealthRawHrvStressPoint _hrvStressPoint(int time) {
return HealthRawHrvStressPoint(
userId: 42,
rawEndTime: time,
rawHrv: 30,
result: 30,
sourceStartTime: time,
sourceEndTime: time,
state: HealthRawStressState.normal,
baselineHrv: 30,
baselineAwakeHrv: 30,
baselineSleepHrv: null,
baselineRestingHr: 60,
);
}
class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
_FakeHealthKitRawDataHostApi({List<String>? operationLog})
: operationLog = operationLog ?? <String>[];
final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {};
final List<HealthKitRawWorkoutDataPoint> _workoutPoints = [];
final List<HealthKitRawDataPoint> _sleepPoints = [];
final List<_ReadCall> calls = [];
final List<String> operationLog;
var sleepCallCount = 0;
var workoutCallCount = 0;
var hrUploadCallCount = 0;
var hrvUploadCallCount = 0;
var dailyStressUploadCallCount = 0;
var sleepUploadCallCount = 0;
var hrUploadUntil = 0;
var hrvUploadUntil = 0;
var dailyStressUploadUntil = 0;
var sleepUploadUntil = 0;
var hasData = true;
... ... @@ -519,16 +716,25 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
}
@override
Future<int> performHRDataUpload({required String sqliteFilePath}) async => 0;
Future<int> performHRDataUpload({required String sqliteFilePath}) async {
hrUploadCallCount += 1;
return hrUploadUntil;
}
@override
Future<int> performHRVDataUpload({required String sqliteFilePath}) async => 0;
Future<int> performHRVDataUpload({required String sqliteFilePath}) async {
hrvUploadCallCount += 1;
return hrvUploadUntil;
}
@override
Future<int> performAvgRealtimeStressDataUpload({
required String sqliteFilePath,
}) async =>
0;
}) async {
operationLog.add('performAvgRealtimeStressDataUpload');
dailyStressUploadCallCount += 1;
return dailyStressUploadUntil;
}
@override
Future<int> performSleepAnalysisDataUpload({
... ... @@ -588,11 +794,14 @@ class _ReadCall {
}
class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
_MemoryHealthRawStressLocalStore({List<String>? operationLog})
: operationLog = operationLog ?? <String>[];
final Map<int, List<HealthRawHrvStressPoint>> _hrv = {};
final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {};
final Map<int, List<HealthRawDailyStressPoint>> _daily = {};
final Map<int, List<HealthRawSleepResult>> _sleep = {};
final List<String> operationLog = [];
final List<String> operationLog;
void insertDailyStress(HealthRawDailyStressPoint point) {
final byDate = <int, HealthRawDailyStressPoint>{
... ... @@ -683,6 +892,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
existing.stressValue == point.stressValue &&
existing.stressScore == point.stressScore &&
existing.state == point.state;
final isToday = point.date == _todayDateKey();
byDate[point.date] = HealthRawDailyStressPoint(
userId: point.userId,
date: point.date,
... ... @@ -690,7 +900,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
stressScore: point.stressScore,
state: point.state,
dataTime: point.dataTime,
uploaded: same ? existing.uploaded : false,
uploaded: isToday ? false : (same ? existing.uploaded : false),
);
}
_daily[userId] = byDate.values.toList()
... ... @@ -737,6 +947,14 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<void> clearTables(int userId) async {
_hrv.remove(userId);
_realtime.remove(userId);
_daily.remove(userId);
_sleep.remove(userId);
}
@override
Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({
required int userId,
required int startTime,
... ... @@ -1068,4 +1286,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'uploaded': result.uploaded ? 1 : 0,
};
}
int _todayDateKey() {
final now = DateTime.now();
return now.year * 10000 + now.month * 100 + now.day;
}
}
... ...