|
| 1 | +import PocketCastsUtils |
| 2 | +import Foundation |
| 3 | +import GRDB |
| 4 | + |
| 5 | +public struct CellularDataUsageManager { |
| 6 | + static let tableName = "CellularDataUsage" |
| 7 | + private let dbQueue: GRDBQueue |
| 8 | + |
| 9 | + init(dbQueue: GRDBQueue) { |
| 10 | + self.dbQueue = dbQueue |
| 11 | + } |
| 12 | + |
| 13 | + // MARK: - Adding |
| 14 | + |
| 15 | + @discardableResult |
| 16 | + public func add( |
| 17 | + episodeUuid: String? = nil, |
| 18 | + podcastUuid: String? = nil, |
| 19 | + bytesDownloaded: Int64 = 0, |
| 20 | + bytesStreamed: Int64 = 0, |
| 21 | + bytesUploaded: Int64 = 0, |
| 22 | + operationType: OperationType, |
| 23 | + connectionType: ConnectionType, |
| 24 | + sessionType: SessionType? = nil, |
| 25 | + timestamp: Date = Date() |
| 26 | + ) -> Bool { |
| 27 | + var record = CellularDataUsageRecord() |
| 28 | + record.timestamp = timestamp.timeIntervalSince1970 |
| 29 | + record.episodeUuid = episodeUuid |
| 30 | + record.podcastUuid = podcastUuid |
| 31 | + record.bytesDownloaded = bytesDownloaded |
| 32 | + record.bytesStreamed = bytesStreamed |
| 33 | + record.bytesUploaded = bytesUploaded |
| 34 | + record.operationType = operationType.rawValue |
| 35 | + record.connectionType = Int32(connectionType.rawValue) |
| 36 | + record.sessionType = sessionType?.rawValue |
| 37 | + |
| 38 | + return dbQueue.insert(&record) |
| 39 | + } |
| 40 | + |
| 41 | + // MARK: - Retrieving |
| 42 | + |
| 43 | + public func totalDataUsage(since date: Date, connectionType: ConnectionType? = nil) -> Int64 { |
| 44 | + let since = date.timeIntervalSince1970 |
| 45 | + |
| 46 | + let result: Int64? = dbQueue.read { db in |
| 47 | + var request = CellularDataUsageRecord |
| 48 | + .filter(CellularDataUsageRecord.Columns.timestamp >= since) |
| 49 | + |
| 50 | + if let connectionType { |
| 51 | + request = request.filter(CellularDataUsageRecord.Columns.connectionType == connectionType.rawValue) |
| 52 | + } |
| 53 | + |
| 54 | + let downloaded = request.select(sum(CellularDataUsageRecord.Columns.bytesDownloaded)) |
| 55 | + let streamed = request.select(sum(CellularDataUsageRecord.Columns.bytesStreamed)) |
| 56 | + let uploaded = request.select(sum(CellularDataUsageRecord.Columns.bytesUploaded)) |
| 57 | + |
| 58 | + let downloadedTotal: Int64 = try Int64.fetchOne(db, downloaded) ?? 0 |
| 59 | + let streamedTotal: Int64 = try Int64.fetchOne(db, streamed) ?? 0 |
| 60 | + let uploadedTotal: Int64 = try Int64.fetchOne(db, uploaded) ?? 0 |
| 61 | + |
| 62 | + return downloadedTotal + streamedTotal + uploadedTotal |
| 63 | + } |
| 64 | + |
| 65 | + return result ?? 0 |
| 66 | + } |
| 67 | + |
| 68 | + public func totalCellularDataUsage(since date: Date) -> Int64 { |
| 69 | + totalDataUsage(since: date, connectionType: .cellular) |
| 70 | + } |
| 71 | + |
| 72 | + public func dataUsageByOperation(since date: Date, connectionType: ConnectionType? = nil) -> [OperationType: Int64] { |
| 73 | + let since = date.timeIntervalSince1970 |
| 74 | + |
| 75 | + struct OperationTotal: Decodable, FetchableRecord { |
| 76 | + let operationType: String |
| 77 | + let total: Int64 |
| 78 | + |
| 79 | + enum CodingKeys: String, CodingKey { |
| 80 | + case operationType = "operation_type" |
| 81 | + case total |
| 82 | + } |
| 83 | + } |
| 84 | + |
| 85 | + let result: [OperationType: Int64]? = dbQueue.read { db in |
| 86 | + var request = CellularDataUsageRecord |
| 87 | + .filter(CellularDataUsageRecord.Columns.timestamp >= since) |
| 88 | + |
| 89 | + if let connectionType { |
| 90 | + request = request.filter(CellularDataUsageRecord.Columns.connectionType == connectionType.rawValue) |
| 91 | + } |
| 92 | + |
| 93 | + // Sum the byte columns and add them together |
| 94 | + // Using SQL literal for the COALESCE(SUM(...), 0) pattern |
| 95 | + let totalExpression = SQL(""" |
| 96 | + COALESCE(SUM(\(CellularDataUsageRecord.Columns.bytesDownloaded)), 0) + \ |
| 97 | + COALESCE(SUM(\(CellularDataUsageRecord.Columns.bytesStreamed)), 0) + \ |
| 98 | + COALESCE(SUM(\(CellularDataUsageRecord.Columns.bytesUploaded)), 0) |
| 99 | + """).sqlExpression |
| 100 | + |
| 101 | + let groupedRequest = request |
| 102 | + .select( |
| 103 | + CellularDataUsageRecord.Columns.operationType, |
| 104 | + totalExpression.forKey("total") |
| 105 | + ) |
| 106 | + .group(CellularDataUsageRecord.Columns.operationType) |
| 107 | + |
| 108 | + let rows = try OperationTotal.fetchAll(db, groupedRequest) |
| 109 | + |
| 110 | + var results: [OperationType: Int64] = [:] |
| 111 | + for row in rows { |
| 112 | + if let opType = OperationType(rawValue: row.operationType) { |
| 113 | + let normalizedOpType = normalizedOperationType(opType) |
| 114 | + results[normalizedOpType, default: 0] += row.total |
| 115 | + } |
| 116 | + } |
| 117 | + return results |
| 118 | + } |
| 119 | + |
| 120 | + return result ?? [:] |
| 121 | + } |
| 122 | + |
| 123 | + public func cellularDataUsageByOperation(since date: Date) -> [OperationType: Int64] { |
| 124 | + dataUsageByOperation(since: date, connectionType: .cellular) |
| 125 | + } |
| 126 | + |
| 127 | + public struct WeeklyUsage { |
| 128 | + public let weekStartDate: Date |
| 129 | + public let totalBytes: Int64 |
| 130 | + } |
| 131 | + |
| 132 | + public struct WeeklyUsageByType { |
| 133 | + public let weekStartDate: Date |
| 134 | + public let bytesByType: [OperationType: Int64] |
| 135 | + |
| 136 | + public var totalBytes: Int64 { |
| 137 | + bytesByType.values.reduce(0, +) |
| 138 | + } |
| 139 | + } |
| 140 | + |
| 141 | + public func weeklyDataUsageByType(forWeeks numberOfWeeks: Int, connectionType: ConnectionType? = .cellular) -> [WeeklyUsageByType] { |
| 142 | + let calendar = Calendar.current |
| 143 | + |
| 144 | + guard let currentWeekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) else { |
| 145 | + return [] |
| 146 | + } |
| 147 | + |
| 148 | + var weekRanges: [(start: Date, end: Date)] = [] |
| 149 | + for weekOffset in (0..<numberOfWeeks).reversed() { |
| 150 | + guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -weekOffset, to: currentWeekStart), |
| 151 | + let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart) else { |
| 152 | + continue |
| 153 | + } |
| 154 | + weekRanges.append((start: weekStart, end: weekEnd)) |
| 155 | + } |
| 156 | + |
| 157 | + guard let oldestWeekStart = weekRanges.first?.start else { |
| 158 | + return [] |
| 159 | + } |
| 160 | + |
| 161 | + struct RecordRow: Decodable, FetchableRecord { |
| 162 | + let timestamp: Double |
| 163 | + let operationType: String |
| 164 | + let total: Int64 |
| 165 | + |
| 166 | + enum CodingKeys: String, CodingKey { |
| 167 | + case timestamp |
| 168 | + case operationType = "operation_type" |
| 169 | + case total |
| 170 | + } |
| 171 | + } |
| 172 | + |
| 173 | + let result: [WeeklyUsageByType]? = dbQueue.read { db in |
| 174 | + var request = CellularDataUsageRecord |
| 175 | + .filter(CellularDataUsageRecord.Columns.timestamp >= oldestWeekStart.timeIntervalSince1970) |
| 176 | + |
| 177 | + if let connectionType { |
| 178 | + request = request.filter(CellularDataUsageRecord.Columns.connectionType == connectionType.rawValue) |
| 179 | + } |
| 180 | + |
| 181 | + // Columns are NOT NULL DEFAULT 0, so no COALESCE needed |
| 182 | + let totalExpression = CellularDataUsageRecord.Columns.bytesDownloaded + |
| 183 | + CellularDataUsageRecord.Columns.bytesStreamed + |
| 184 | + CellularDataUsageRecord.Columns.bytesUploaded |
| 185 | + |
| 186 | + let orderedRequest = request |
| 187 | + .select( |
| 188 | + CellularDataUsageRecord.Columns.timestamp, |
| 189 | + CellularDataUsageRecord.Columns.operationType, |
| 190 | + totalExpression.forKey("total") |
| 191 | + ) |
| 192 | + .order(CellularDataUsageRecord.Columns.timestamp) |
| 193 | + |
| 194 | + let rows = try RecordRow.fetchAll(db, orderedRequest) |
| 195 | + |
| 196 | + var weekTotals: [Date: [OperationType: Int64]] = [:] |
| 197 | + for range in weekRanges { |
| 198 | + weekTotals[range.start] = [:] |
| 199 | + } |
| 200 | + |
| 201 | + for row in rows { |
| 202 | + let recordDate = Date(timeIntervalSince1970: row.timestamp) |
| 203 | + guard let opType = OperationType(rawValue: row.operationType) else { |
| 204 | + continue |
| 205 | + } |
| 206 | + |
| 207 | + let normalizedOpType = normalizedOperationType(opType) |
| 208 | + |
| 209 | + for range in weekRanges { |
| 210 | + if recordDate >= range.start && recordDate < range.end { |
| 211 | + weekTotals[range.start, default: [:]][normalizedOpType, default: 0] += row.total |
| 212 | + break |
| 213 | + } |
| 214 | + } |
| 215 | + } |
| 216 | + |
| 217 | + return weekRanges.map { range in |
| 218 | + WeeklyUsageByType(weekStartDate: range.start, bytesByType: weekTotals[range.start] ?? [:]) |
| 219 | + } |
| 220 | + } |
| 221 | + |
| 222 | + return result ?? [] |
| 223 | + } |
| 224 | + |
| 225 | + public func weeklyDataUsage(forWeeks numberOfWeeks: Int, connectionType: ConnectionType? = .cellular) -> [WeeklyUsage] { |
| 226 | + let calendar = Calendar.current |
| 227 | + |
| 228 | + // Calculate week boundaries starting from the beginning of the current week |
| 229 | + guard let currentWeekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: Date())) else { |
| 230 | + return [] |
| 231 | + } |
| 232 | + |
| 233 | + var weekRanges: [(start: Date, end: Date)] = [] |
| 234 | + for weekOffset in (0..<numberOfWeeks).reversed() { |
| 235 | + guard let weekStart = calendar.date(byAdding: .weekOfYear, value: -weekOffset, to: currentWeekStart), |
| 236 | + let weekEnd = calendar.date(byAdding: .weekOfYear, value: 1, to: weekStart) else { |
| 237 | + continue |
| 238 | + } |
| 239 | + weekRanges.append((start: weekStart, end: weekEnd)) |
| 240 | + } |
| 241 | + |
| 242 | + guard let oldestWeekStart = weekRanges.first?.start else { |
| 243 | + return [] |
| 244 | + } |
| 245 | + |
| 246 | + struct RecordRow: Decodable, FetchableRecord { |
| 247 | + let timestamp: Double |
| 248 | + let total: Int64 |
| 249 | + } |
| 250 | + |
| 251 | + let result: [WeeklyUsage]? = dbQueue.read { db in |
| 252 | + var request = CellularDataUsageRecord |
| 253 | + .filter(CellularDataUsageRecord.Columns.timestamp >= oldestWeekStart.timeIntervalSince1970) |
| 254 | + |
| 255 | + if let connectionType { |
| 256 | + request = request.filter(CellularDataUsageRecord.Columns.connectionType == connectionType.rawValue) |
| 257 | + } |
| 258 | + |
| 259 | + // Columns are NOT NULL DEFAULT 0, so no COALESCE needed |
| 260 | + let totalExpression = CellularDataUsageRecord.Columns.bytesDownloaded + |
| 261 | + CellularDataUsageRecord.Columns.bytesStreamed + |
| 262 | + CellularDataUsageRecord.Columns.bytesUploaded |
| 263 | + |
| 264 | + let orderedRequest = request |
| 265 | + .select( |
| 266 | + CellularDataUsageRecord.Columns.timestamp, |
| 267 | + totalExpression.forKey("total") |
| 268 | + ) |
| 269 | + .order(CellularDataUsageRecord.Columns.timestamp) |
| 270 | + |
| 271 | + let rows = try RecordRow.fetchAll(db, orderedRequest) |
| 272 | + |
| 273 | + var weekTotals: [Date: Int64] = [:] |
| 274 | + for range in weekRanges { |
| 275 | + weekTotals[range.start] = 0 |
| 276 | + } |
| 277 | + |
| 278 | + for row in rows { |
| 279 | + let recordDate = Date(timeIntervalSince1970: row.timestamp) |
| 280 | + |
| 281 | + for range in weekRanges { |
| 282 | + if recordDate >= range.start && recordDate < range.end { |
| 283 | + weekTotals[range.start, default: 0] += row.total |
| 284 | + break |
| 285 | + } |
| 286 | + } |
| 287 | + } |
| 288 | + |
| 289 | + return weekRanges.map { range in |
| 290 | + WeeklyUsage(weekStartDate: range.start, totalBytes: weekTotals[range.start] ?? 0) |
| 291 | + } |
| 292 | + } |
| 293 | + |
| 294 | + return result ?? [] |
| 295 | + } |
| 296 | + |
| 297 | + // MARK: - Cleanup |
| 298 | + |
| 299 | + @discardableResult |
| 300 | + public func deleteRecords(olderThan date: Date) async -> Bool { |
| 301 | + let before = date.timeIntervalSince1970 |
| 302 | + let filter = CellularDataUsageRecord.Columns.timestamp < before |
| 303 | + let deletedCount = dbQueue.deleteAll(CellularDataUsageRecord.self, filter: filter) |
| 304 | + return deletedCount >= 0 |
| 305 | + } |
| 306 | + |
| 307 | + // MARK: - Enums |
| 308 | + |
| 309 | + public enum OperationType: String { |
| 310 | + case download // Legacy, kept for backward compatibility with existing data |
| 311 | + case stream |
| 312 | + case sync |
| 313 | + case upload |
| 314 | + case api |
| 315 | + case autoDownload |
| 316 | + } |
| 317 | + |
| 318 | + public enum ConnectionType: Int { |
| 319 | + case unknown = 0 |
| 320 | + case wifi = 1 |
| 321 | + case cellular = 2 |
| 322 | + } |
| 323 | + |
| 324 | + public enum SessionType: String { |
| 325 | + case background |
| 326 | + case foreground |
| 327 | + } |
| 328 | + |
| 329 | + private func normalizedOperationType(_ operationType: OperationType) -> OperationType { |
| 330 | + switch operationType { |
| 331 | + case .autoDownload: |
| 332 | + return .download |
| 333 | + default: |
| 334 | + return operationType |
| 335 | + } |
| 336 | + } |
| 337 | +} |
| 338 | + |
| 339 | +// MARK: - Schema Creation |
| 340 | +extension CellularDataUsageManager { |
| 341 | + static func createTable(in db: PCDatabase) throws { |
| 342 | + try db.executeUpdate(""" |
| 343 | + CREATE TABLE IF NOT EXISTS \(Self.tableName) ( |
| 344 | + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 345 | + timestamp REAL NOT NULL, |
| 346 | + episode_uuid TEXT, |
| 347 | + podcast_uuid TEXT, |
| 348 | + bytes_downloaded INTEGER NOT NULL DEFAULT 0, |
| 349 | + bytes_streamed INTEGER NOT NULL DEFAULT 0, |
| 350 | + bytes_uploaded INTEGER NOT NULL DEFAULT 0, |
| 351 | + operation_type TEXT NOT NULL, |
| 352 | + connection_type INTEGER NOT NULL, |
| 353 | + session_type TEXT |
| 354 | + ); |
| 355 | + """, values: nil) |
| 356 | + |
| 357 | + try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_timestamp ON \(Self.tableName) (timestamp);", values: nil) |
| 358 | + try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_episode ON \(Self.tableName) (episode_uuid);", values: nil) |
| 359 | + try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_connection ON \(Self.tableName) (connection_type);", values: nil) |
| 360 | + } |
| 361 | +} |
0 commit comments