Skip to content

Commit bfd53e0

Browse files
committed
Add cellular data usage tracking schema
Adds database infrastructure for tracking data usage: - CellularDataUsageRecord GRDB model with operation type, connection type, and bytes - CellularDataUsageManager with record/query methods - Weekly aggregation queries for reporting
1 parent 6508cd9 commit bfd53e0

File tree

16 files changed

+35552
-0
lines changed

16 files changed

+35552
-0
lines changed

Modules/DataModel/Sources/PocketCastsDataModel/Private/Managers/Util/DatabaseHelper.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,34 @@ class DatabaseHelper {
882882
}
883883
}
884884

885+
if schemaVersion < 72 {
886+
do {
887+
try db.executeUpdate("""
888+
CREATE TABLE IF NOT EXISTS CellularDataUsage (
889+
id INTEGER PRIMARY KEY AUTOINCREMENT,
890+
timestamp REAL NOT NULL,
891+
episode_uuid TEXT,
892+
podcast_uuid TEXT,
893+
bytes_downloaded INTEGER NOT NULL DEFAULT 0,
894+
bytes_streamed INTEGER NOT NULL DEFAULT 0,
895+
bytes_uploaded INTEGER NOT NULL DEFAULT 0,
896+
operation_type TEXT NOT NULL,
897+
connection_type INTEGER NOT NULL,
898+
session_type TEXT
899+
);
900+
""", values: nil)
901+
902+
try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_timestamp ON CellularDataUsage (timestamp);", values: nil)
903+
try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_episode ON CellularDataUsage (episode_uuid);", values: nil)
904+
try db.executeUpdate("CREATE INDEX IF NOT EXISTS cellular_data_connection ON CellularDataUsage (connection_type);", values: nil)
905+
906+
schemaVersion = 72
907+
} catch {
908+
failedAt(72)
909+
return
910+
}
911+
}
912+
885913
db.commit()
886914
}
887915
}
Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
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

Comments
 (0)