HealthRawStressSQLiteUploader.swift 8.86 KB
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
    }
  }
}