HealthRawStressSQLiteUploader.swift
8.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
import Foundation
import SQLite3
enum HealthRawStressUploadKind {
case hrv
case realtimeStress
case dailyStress
}
enum HealthRawStressSQLiteUploadError: LocalizedError {
case invalidDatabasePath
case openDatabaseFailed(String)
case prepareFailed(String)
case invalidServerURL
case missingAccessToken
case invalidResponse
case requestFailed(path: String, statusCode: Int, body: String?)
var errorDescription: String? {
switch self {
case .invalidDatabasePath:
return "Invalid HealthRawData sqlite file path."
case .openDatabaseFailed(let message):
return "Open HealthRawData sqlite failed: \(message)"
case .prepareFailed(let message):
return "Prepare HealthRawData sqlite query failed: \(message)"
case .invalidServerURL:
return "Invalid server URL."
case .missingAccessToken:
return "Missing access token."
case .invalidResponse:
return "Invalid upload response."
case .requestFailed(let path, let statusCode, let body):
return "Upload \(path) failed status=\(statusCode) body=\(body ?? "nil")"
}
}
}
final class HealthRawStressSQLiteUploader {
static let shared = HealthRawStressSQLiteUploader()
private let session: URLSession
private let batchSize = 200
init(session: URLSession = .shared) {
self.session = session
}
func uploadHrv(sqliteFilePath: String) async throws -> Int64 {
let rows = try queryRows(
sqliteFilePath: sqliteFilePath,
sql: """
SELECT raw_end_time, raw_hrv, result, state, baseline_resting_hr, baseline_hrv,
is_sleep_likely, is_workout, is_workout_recovery, is_suspected_activity
FROM hrv_results
WHERE uploaded != 1
ORDER BY raw_end_time ASC
"""
)
var uploadedUntil: Int64 = 0
for batch in batches(rows) {
let list = batch.map { row in
[
"data_time": row.int64("raw_end_time"),
"raw_hrv": row.double("raw_hrv"),
"trend_hrv": row.double("result"),
"state": row.int("state"),
"hr_baseline": row.double("baseline_resting_hr"),
"hrv_baseline": row.double("baseline_hrv"),
"is_asleep": row.int("is_sleep_likely"),
"is_workout": row.int("is_workout"),
"is_workout_recovery": row.int("is_workout_recovery"),
"is_suspected_activity": row.int("is_suspected_activity"),
] as [String: Any]
}
try await upload(
path: "/client/doublefeel/health/v2/hrv_trend/",
body: ["data_list": list]
)
uploadedUntil = batch.last?.int64("raw_end_time") ?? uploadedUntil
}
return uploadedUntil
}
func uploadRealtimeStress(sqliteFilePath: String) async throws -> Int64 {
let rows = try queryRows(
sqliteFilePath: sqliteFilePath,
sql: """
SELECT raw_end_time, raw_hr, result, is_sleep_likely, is_workout,
is_workout_recovery, is_suspected_activity
FROM realtime_stress_results
WHERE uploaded != 1
ORDER BY raw_end_time ASC
"""
)
var uploadedUntil: Int64 = 0
for batch in batches(rows) {
let list = batch.map { row in
let stressValue = row.double("result")
return [
"data_time": row.int64("raw_end_time"),
"hr_value": row.double("raw_hr"),
"stress_value": stressValue,
"state": stressState(stressValue),
"is_asleep": row.int("is_sleep_likely"),
"is_workout": row.int("is_workout"),
"is_workout_recovery": row.int("is_workout_recovery"),
"is_suspected_activity": row.int("is_suspected_activity"),
] as [String: Any]
}
try await upload(
path: "/client/doublefeel/health/v2/realtime_stress/",
body: ["data_list": list]
)
uploadedUntil = batch.last?.int64("raw_end_time") ?? uploadedUntil
}
return uploadedUntil
}
func uploadDailyStress(sqliteFilePath: String) async throws -> Bool {
let rows = try queryRows(
sqliteFilePath: sqliteFilePath,
sql: """
SELECT stress_value, stress_score, state, data_time
FROM daily_stress_results
WHERE uploaded != 1
ORDER BY date ASC
"""
)
for batch in batches(rows) {
let list = batch.map { row in
[
"stress_value": row.double("stress_value"),
"stress_score": row.int("stress_score"),
"state": row.int("state"),
"data_time": row.int64("data_time"),
] as [String: Any]
}
try await upload(
path: "/client/doublefeel/health/v2/stress_score/",
body: ["data_list": list]
)
}
return true
}
private func queryRows(sqliteFilePath: String, sql: String) throws -> [SQLiteRow] {
guard FileManager.default.fileExists(atPath: sqliteFilePath) else {
throw HealthRawStressSQLiteUploadError.invalidDatabasePath
}
var db: OpaquePointer?
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX
guard sqlite3_open_v2(sqliteFilePath, &db, flags, nil) == SQLITE_OK,
let db else {
let message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "unknown"
if let db { sqlite3_close(db) }
throw HealthRawStressSQLiteUploadError.openDatabaseFailed(message)
}
defer { sqlite3_close(db) }
var statement: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK,
let statement else {
throw HealthRawStressSQLiteUploadError.prepareFailed(
String(cString: sqlite3_errmsg(db))
)
}
defer { sqlite3_finalize(statement) }
var rows: [SQLiteRow] = []
while sqlite3_step(statement) == SQLITE_ROW {
var values: [String: SQLiteValue] = [:]
for index in 0..<sqlite3_column_count(statement) {
let name = String(cString: sqlite3_column_name(statement, index))
values[name] = SQLiteValue(statement: statement, index: index)
}
rows.append(SQLiteRow(values: values))
}
return rows
}
private func batches(_ values: [SQLiteRow]) -> [[SQLiteRow]] {
guard batchSize > 0 else { return [values] }
return stride(from: 0, to: values.count, by: batchSize).map {
Array(values[$0..<Swift.min($0 + batchSize, values.count)])
}
}
private func upload(path: String, body: [String: Any]) async throws {
guard let baseURL = URL(string: AppShared.shared.baseUrl),
let url = URL(string: path, relativeTo: baseURL)?.absoluteURL else {
throw HealthRawStressSQLiteUploadError.invalidServerURL
}
guard let accessToken = AppShared.shared.token, !accessToken.isEmpty else {
throw HealthRawStressSQLiteUploadError.missingAccessToken
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.timeoutInterval = 60
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(accessToken, forHTTPHeaderField: "access_token")
request.setValue(AppShared.shared.agent.finalUA, forHTTPHeaderField: "User-Agent")
request.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw HealthRawStressSQLiteUploadError.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
if httpResponse.statusCode == 401 {
await MainActor.run { AppShared.shared.logout() }
}
throw HealthRawStressSQLiteUploadError.requestFailed(
path: path,
statusCode: httpResponse.statusCode,
body: String(data: data, encoding: .utf8)
)
}
}
private func stressState(_ value: Double) -> Int {
if value >= 81 { return 1 }
if value >= 61 { return 2 }
if value >= 21 { return 3 }
return 4
}
}
private enum SQLiteValue {
case integer(Int64)
case double(Double)
case text(String)
case null
init(statement: OpaquePointer, index: Int32) {
switch sqlite3_column_type(statement, index) {
case SQLITE_INTEGER:
self = .integer(sqlite3_column_int64(statement, index))
case SQLITE_FLOAT:
self = .double(sqlite3_column_double(statement, index))
case SQLITE_TEXT:
self = .text(String(cString: sqlite3_column_text(statement, index)))
default:
self = .null
}
}
}
private struct SQLiteRow {
let values: [String: SQLiteValue]
func int64(_ key: String) -> Int64 {
switch values[key] {
case .integer(let value): return value
case .double(let value): return Int64(value)
case .text(let value): return Int64(value) ?? 0
case .null, .none: return 0
}
}
func int(_ key: String) -> Int {
Int(int64(key))
}
func double(_ key: String) -> Double {
switch values[key] {
case .integer(let value): return Double(value)
case .double(let value): return value
case .text(let value): return Double(value) ?? 0
case .null, .none: return 0
}
}
}