TempTrack-iOS/TempTrack/Storage/PersistentStorage.swift
2023-07-02 17:29:39 +02:00

342 lines
12 KiB
Swift

import Foundation
import Combine
import BinaryCodable
import SwiftUI
final class PersistentStorage: ObservableObject {
static var documentDirectory: URL {
try! FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
appropriateFor: nil, create: true)
}
@AppStorage("newestDate")
private var newestMeasurementTime: Int = 0
@AppStorage("deviceTime")
private var lastDeviceTimeData: Data?
/**
The date of the latest measurement.
Incoming data older than this date will be rejected to prevent duplicate measurements
*/
private var newestMeasurementDate: Date {
get {
Date(seconds: newestMeasurementTime)
}
set {
newestMeasurementTime = newValue.seconds
}
}
@Published
var recentMeasurements: [TemperatureMeasurement]
@Published
var dailyMeasurementCounts: [MeasurementDailyCount] = []
/// The formatter for the temperature measurement file names
private let fileNameFormatter: DateFormatter
/// The storage of daily temperature measurements
private let temperatureStorageFolderUrl: URL
/// The storage of the measurement counts per day
private let dailyCountsFileUrl: URL
private let fm: FileManager
/// The interval in which the measurements should be kept in `recentMeasurements`
private let lastValueInterval: TimeInterval
init(lastMeasurements: [TemperatureMeasurement] = [], lastValueInterval: TimeInterval = 3600) {
self.recentMeasurements = lastMeasurements
let documentDirectory = PersistentStorage.documentDirectory
self.temperatureStorageFolderUrl = documentDirectory.appendingPathComponent("measurements")
self.dailyCountsFileUrl = documentDirectory.appendingPathComponent("overview.bin")
self.fm = .default
self.fileNameFormatter = DateFormatter()
self.fileNameFormatter.dateFormat = "yyyyMMdd.bin"
self.lastValueInterval = lastValueInterval
if lastMeasurements.isEmpty {
loadLastMeasurements()
loadDailyCounts()
} else {
setDailyCounts(from: lastMeasurements)
}
ensureExistenceOfFolder()
//recalculateDailyCounts()
}
private func ensureExistenceOfFolder() {
guard !fm.fileExists(atPath: temperatureStorageFolderUrl.path) else {
return
}
do {
try fm.createDirectory(at: temperatureStorageFolderUrl, withIntermediateDirectories: true)
} catch {
log.error("Failed to create folder: \(error)")
}
}
private func fileName(for date: Date) -> String {
fileNameFormatter.string(from: date)
}
private func fileName(for index: Int) -> String {
String(format: "%08d.bin", index)
}
private func fileUrl(for dateIndex: Int) -> URL {
temperatureStorageFolderUrl.appendingPathComponent(fileName(for: dateIndex))
}
private func fileUrl(for fileName: String) -> URL {
temperatureStorageFolderUrl.appendingPathComponent(fileName)
}
private func loadLastMeasurements() {
let now = Date.now
let startDate = now.addingTimeInterval(-lastValueInterval)
let todayIndex = now.dateIndex
let todayValues = loadMeasurements(for: todayIndex)
.filter { $0.date >= startDate }
let dateIndexOfStart = startDate.dateIndex
guard todayIndex != dateIndexOfStart else {
recentMeasurements = todayValues
log.info("Loaded \(recentMeasurements.count) recent measurements")
return
}
let yesterdayValues = loadMeasurements(for: dateIndexOfStart)
.filter { $0.date >= startDate }
recentMeasurements = todayValues + yesterdayValues
log.info("Loaded \(recentMeasurements.count) recent measurements")
}
private func updateLastMeasurements(_ measurements: [TemperatureMeasurement]) {
let startDate = Date().addingTimeInterval(-lastValueInterval).seconds
recentMeasurements = (measurements + recentMeasurements)
.filter { $0.id > startDate }
log.info("\(recentMeasurements.count) recent measurements (with \(measurements.count) new entries)")
}
private func loadMeasurements(for date: Date) -> [TemperatureMeasurement] {
loadMeasurements(from: fileName(for: date))
}
func loadMeasurements(for dateIndex: Int) -> [TemperatureMeasurement] {
loadMeasurements(from: fileName(for: dateIndex))
}
private func loadMeasurements(from fileName: String) -> [TemperatureMeasurement] {
let fileUrl = fileUrl(for: fileName)
guard fm.fileExists(atPath: fileUrl.path) else {
log.info("No measurements for \(fileName)")
return []
}
do {
let content = try Data(contentsOf: fileUrl)
let points: [TemperatureMeasurement] = try BinaryDecoder.decode(from: content)
log.info("Loaded \(points.count) points from \(fileName)")
return points
} catch {
log.error("Failed to read file \(fileName): \(error)")
return []
}
}
func add(_ measurements: [TemperatureMeasurement]) {
let lastDate = self.newestMeasurementDate.seconds
let newerValues: [TemperatureMeasurement] = measurements.filter { $0.id > lastDate }.reversed()
let newValues = newerValues.splitByDate()
log.info("Adding \(newerValues.count) of \(measurements.count) measurements")
for (dateIndex, values) in newValues {
let count = saveNew(values, for: dateIndex)
setDailyCount(count, for: dateIndex)
}
saveDailyCounts()
updateLastMeasurements(measurements)
if let newest = newerValues.max()?.id {
newestMeasurementTime = newest
}
}
func removeMeasurements(for dateIndex: Int) {
let fileUrl = fileUrl(for: dateIndex)
guard fm.fileExists(atPath: fileUrl.path) else {
log.warning("No measurements for \(fileUrl.lastPathComponent)")
return
}
do {
try fm.removeItem(at: fileUrl)
dailyMeasurementCounts = dailyMeasurementCounts.filter { $0.dateIndex != dateIndex }
recentMeasurements = recentMeasurements.filter { $0.date.dateIndex != dateIndex }
} catch {
log.error("Failed to delete \(fileUrl.lastPathComponent): \(error)")
}
}
/**
- Returns: The number of new points
*/
private func saveNew(_ measurements: [TemperatureMeasurement], for dateIndex: Int) -> Int {
let fileName = fileName(for: dateIndex)
let values = measurements + loadMeasurements(from: fileName)
save(values, for: fileName)
return values.count
}
private func save(_ measurements: [TemperatureMeasurement], for fileName: String) {
let fileUrl = fileUrl(for: fileName)
do {
let data = try BinaryEncoder.encode(measurements.sorted().reversed())
try data.write(to: fileUrl)
} catch {
log.error("Failed to save \(fileName): \(error)")
}
}
// MARK: Daily counts
private func setDailyCount(_ count: Int, for dateIndex: Int) {
guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else {
add(dailyCount: count, for: dateIndex)
return
}
dailyMeasurementCounts[index].count = count
}
private func add(dailyCount count: Int, for dateIndex: Int) {
let entry = MeasurementDailyCount(dateIndex: dateIndex, count: count)
let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex > dateIndex }) ?? 0
dailyMeasurementCounts.insert(entry, at: index)
}
private func incrementCount(for dateIndex: Int, by increment: Int = 1) {
guard let index = dailyMeasurementCounts.firstIndex(where: { $0.dateIndex == dateIndex }) else {
add(dailyCount: increment, for: dateIndex)
return
}
dailyMeasurementCounts[index].count += increment
}
private func loadDailyCounts() {
do {
let data = try Data(contentsOf: dailyCountsFileUrl)
dailyMeasurementCounts = try BinaryDecoder.decode(from: data)
} catch {
log.error("Failed to load overview: \(error)")
}
}
private func saveDailyCounts() {
do {
let data = try BinaryEncoder.encode(dailyMeasurementCounts)
try data.write(to: dailyCountsFileUrl)
} catch {
log.error("Failed to write overview: \(error)")
}
}
private func setDailyCounts(from measurements: [TemperatureMeasurement]) {
self.dailyMeasurementCounts = measurements.reduce(into: [Int: Int]()) { counts, value in
let index = value.date.dateIndex
counts[index] = (counts[index] ?? 0) + 1
}
.map { MeasurementDailyCount(dateIndex: $0.key, count: $0.value) }
.sorted()
.reversed()
}
func recalculateDailyCounts() {
do {
let files = try fm.contentsOfDirectory(atPath: temperatureStorageFolderUrl.path)
let newValues: [Int: Int] = files
.reduce(into: [:]) { counts, fileName in
let dateString = fileName.replacingOccurrences(of: ".bin", with: "")
guard let dateIndex = Int(dateString) else {
log.warning("Found file with invalid name \(fileName)")
return
}
counts[dateIndex] = loadMeasurements(from: fileName).count
}
DispatchQueue.main.async {
self.dailyMeasurementCounts = newValues
.map { .init(dateIndex: $0.key, count: $0.value) }
.sorted()
.reversed()
log.info("Daily counts recalculated from \(files.count) files")
}
} catch {
log.error("Failed to load daily counts: \(error)")
}
}
// MARK: Device time
var lastDeviceTime: DeviceTime? {
get {
guard let data = lastDeviceTimeData else {
return nil
}
do {
let result: DeviceTime = try BinaryDecoder.decode(from: data)
return result
} catch {
log.error("Failed to decode device time: \(error)")
lastDeviceTimeData = nil
return nil
}
}
set {
guard let newValue else {
lastDeviceTimeData = nil
return
}
do {
let data = try BinaryEncoder.encode(newValue)
lastDeviceTimeData = data
} catch {
log.error("Failed to encode device time: \(error)")
lastDeviceTimeData = nil
}
}
}
}
private extension Array where Element == TemperatureMeasurement {
@discardableResult
mutating func insertIntoSorted(_ measurement: TemperatureMeasurement) -> Bool {
guard !contains(measurement) else {
return false
}
guard let index = self.firstIndex(where: { $0.date > measurement.date }) else {
append(measurement)
return true
}
insert(measurement, at: index)
return true
}
func splitByDate() -> [Int : [TemperatureMeasurement]] {
reduce(into: [:]) { result, value in
let dateIndex = value.date.dateIndex
result[dateIndex] = (result[dateIndex] ?? []) + [value]
}
}
}
extension PersistentStorage {
static var mock: PersistentStorage {
.init(lastMeasurements: TemperatureMeasurement.mockData)
}
}