diff --git a/AppCheck.xcodeproj/project.pbxproj b/AppCheck.xcodeproj/project.pbxproj index e1cb00b..ca8b524 100644 --- a/AppCheck.xcodeproj/project.pbxproj +++ b/AppCheck.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 540C6457240D929300E948F9 /* EditableRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540C6456240D929300E948F9 /* EditableRows.swift */; }; + 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E677F242D2CF100871BBE /* VCRecordings.swift */; }; + 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67812433483D00871BBE /* VCEditRecording.swift */; }; + 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */; }; 541A957623E602DF00C09C19 /* LaunchIcon.png in Resources */ = {isa = PBXBuildFile; fileRef = 541A957523E602DF00C09C19 /* LaunchIcon.png */; }; 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 541AC5D72399498A00A769D7 /* AppDelegate.swift */; }; 541AC5DD2399498A00A769D7 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 541AC5DB2399498A00A769D7 /* Main.storyboard */; }; @@ -32,7 +35,7 @@ 54B345A6241BB982004C53CC /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A5241BB982004C53CC /* Notifications.swift */; }; 54B345A9241BBA0B004C53CC /* Generic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345A8241BBA0B004C53CC /* Generic.swift */; }; 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AA241BBA5B004C53CC /* AlertSheet.swift */; }; - 54B345AD241BBB00004C53CC /* GroupedDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* GroupedDomain.swift */; }; + 54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54B345AC241BBB00004C53CC /* DBExtensions.swift */; }; 54B345B0242264F8004C53CC /* third-level.txt in Resources */ = {isa = PBXBuildFile; fileRef = 54B345AF242264F8004C53CC /* third-level.txt */; }; 54C056DB23E9E36E00214A3F /* AppInfoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DA23E9E36E00214A3F /* AppInfoType.swift */; }; 54C056DD23E9EEF700214A3F /* BundleIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54C056DC23E9EEF700214A3F /* BundleIcon.swift */; }; @@ -152,6 +155,9 @@ /* Begin PBXFileReference section */ 540C6456240D929300E948F9 /* EditableRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditableRows.swift; sourceTree = ""; }; + 540E677F242D2CF100871BBE /* VCRecordings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCRecordings.swift; sourceTree = ""; }; + 540E67812433483D00871BBE /* VCEditRecording.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VCEditRecording.swift; sourceTree = ""; }; + 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TVCPreviousRecords.swift; sourceTree = ""; }; 541A957523E602DF00C09C19 /* LaunchIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = LaunchIcon.png; sourceTree = ""; }; 541AC5D42399498A00A769D7 /* AppCheck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AppCheck.app; sourceTree = BUILT_PRODUCTS_DIR; }; 541AC5D72399498A00A769D7 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -178,7 +184,7 @@ 54B345A5241BB982004C53CC /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; 54B345A8241BBA0B004C53CC /* Generic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generic.swift; sourceTree = ""; }; 54B345AA241BBA5B004C53CC /* AlertSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertSheet.swift; sourceTree = ""; }; - 54B345AC241BBB00004C53CC /* GroupedDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupedDomain.swift; sourceTree = ""; }; + 54B345AC241BBB00004C53CC /* DBExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBExtensions.swift; sourceTree = ""; }; 54B345AF242264F8004C53CC /* third-level.txt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "third-level.txt"; sourceTree = ""; }; 54B7562223D7B2DC008F0C41 /* SQDB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SQDB.swift; sourceTree = ""; }; 54C056DA23E9E36E00214A3F /* AppInfoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoType.swift; sourceTree = ""; }; @@ -313,6 +319,16 @@ path = Settings; sourceTree = ""; }; + 540E677E242D2CD200871BBE /* Recordings */ = { + isa = PBXGroup; + children = ( + 540E677F242D2CF100871BBE /* VCRecordings.swift */, + 540E67832433FAFE00871BBE /* TVCPreviousRecords.swift */, + 540E67812433483D00871BBE /* VCEditRecording.swift */, + ); + path = Recordings; + sourceTree = ""; + }; 541AC5CB2399498A00A769D7 = { isa = PBXGroup; children = ( @@ -342,6 +358,7 @@ 542E2A972404973F001462DC /* TBCMain.swift */, 54B34597240F18DD004C53CC /* TVC Extensions */, 540C6454240D5BAE00E948F9 /* Requests */, + 540E677E242D2CD200871BBE /* Recordings */, 540C6455240D5BD200E948F9 /* Settings */, 54B345B12422E029004C53CC /* unused */, 541AC5DB2399498A00A769D7 /* Main.storyboard */, @@ -399,8 +416,8 @@ 544C95252407B1C700AB89D0 /* SharedState.swift */, 54B345A8241BBA0B004C53CC /* Generic.swift */, 54B345A5241BB982004C53CC /* Notifications.swift */, + 54B345AC241BBB00004C53CC /* DBExtensions.swift */, 54B345AA241BBA5B004C53CC /* AlertSheet.swift */, - 54B345AC241BBB00004C53CC /* GroupedDomain.swift */, 54B34595240F0513004C53CC /* TableView.swift */, 54751E502423955000168273 /* FileManager.swift */, ); @@ -772,12 +789,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 54B345AD241BBB00004C53CC /* GroupedDomain.swift in Sources */, + 54B345AD241BBB00004C53CC /* DBExtensions.swift in Sources */, + 540E67842433FAFE00871BBE /* TVCPreviousRecords.swift in Sources */, 54B345A6241BB982004C53CC /* Notifications.swift in Sources */, 54B345AB241BBA5B004C53CC /* AlertSheet.swift in Sources */, 544C95262407B1C700AB89D0 /* SharedState.swift in Sources */, 54B345A9241BBA0B004C53CC /* Generic.swift in Sources */, 54B34596240F0513004C53CC /* TableView.swift in Sources */, + 540E6780242D2CF100871BBE /* VCRecordings.swift in Sources */, 54953E3323DC752E0054345C /* SQDB.swift in Sources */, 54953E6123E0D69A0054345C /* TVCHosts.swift in Sources */, 540C6457240D929300E948F9 /* EditableRows.swift in Sources */, @@ -790,6 +809,7 @@ 542E2A982404973F001462DC /* TBCMain.swift in Sources */, 541AC5D82399498A00A769D7 /* AppDelegate.swift in Sources */, 54B345992414F491004C53CC /* DBWrapper.swift in Sources */, + 540E67822433483D00871BBE /* VCEditRecording.swift in Sources */, 54B34594240E6343004C53CC /* TVCFilter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/main/Base.lproj/Main.storyboard b/main/Base.lproj/Main.storyboard index 81b3fcb..0a85bd8 100644 --- a/main/Base.lproj/Main.storyboard +++ b/main/Base.lproj/Main.storyboard @@ -1,5 +1,5 @@ - + @@ -17,11 +17,11 @@ - + - + - + @@ -54,7 +54,7 @@ - + @@ -65,11 +65,11 @@ - + - + - + @@ -102,7 +102,7 @@ - + @@ -140,7 +140,7 @@ - + @@ -157,7 +157,7 @@ - + @@ -178,15 +178,15 @@ - - + + - + + + + + + + + + + + + + + - + @@ -394,7 +457,7 @@ - + @@ -402,7 +465,7 @@ - + @@ -427,12 +490,157 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1. Line +2. Line +3. Line +4. Line + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Start: 1970-01-01 01:00 +End: 1970-01-01 02:00 +Duration: 60:00 + + + + + + + + + + + + + + + + + + + @@ -441,6 +649,6 @@ - + diff --git a/main/DB/DBWrapper.swift b/main/DB/DBWrapper.swift index 7eb24e6..7ad52c5 100644 --- a/main/DB/DBWrapper.swift +++ b/main/DB/DBWrapper.swift @@ -1,6 +1,7 @@ import UIKit let DBWrp = DBWrapper() +fileprivate var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } } class DBWrapper { private var latestModification: Timestamp = 0 @@ -49,11 +50,11 @@ class DBWrapper { func initContentOfDB() { DispatchQueue.global().async { #if IOS_SIMULATOR -// self.generateTestData() -// DispatchQueue.main.async { -// // dont know why main queue is needed, wont start otherwise -// Timer.repeating(2, call: #selector(self.insertRandomEntry), on: self) -// } + self.generateTestData() + DispatchQueue.main.async { + // dont know why main queue is needed, wont start otherwise + Timer.repeating(2, call: #selector(self.insertRandomEntry), on: self) + } #endif self.dataF_init() self.dataAB_init() @@ -100,7 +101,7 @@ class DBWrapper { // MARK: - Partial Update History @objc private func syncNewestLogs() { - QLog.Debug("\(#function)") + //QLog.Debug("\(#function)") #if !IOS_SIMULATOR guard currentVPNState == .on else { return } #endif @@ -220,6 +221,25 @@ class DBWrapper { } + // MARK: - Recordings + + func listOfRecordings() -> [Recording] { AppDB?.allRecordings() ?? [] } + func recordingGetCurrent() -> Recording? { AppDB?.ongoingRecording() } + func recordingStartNew() -> Recording? { try? AppDB?.startNewRecording() } + func recordingStopAll() { AppDB?.stopRecordings() } + + func recordingUpdate(_ r: Recording) { + AppDB?.updateRecording(r) + NotifyRecordingChanged.post((r, false)) + } + + func recordingDelete(_ r: Recording) { + if (try? AppDB?.deleteRecording(r)) == true { + NotifyRecordingChanged.post((r, true)) + } + } + + // MARK: - Helper methods private func dataA_index(of domain: String) -> Int? { @@ -270,7 +290,7 @@ extension DBWrapper { } @objc private func insertRandomEntry() { - QLog.Debug("Inserting 1 periodic log entry") + //QLog.Debug("Inserting 1 periodic log entry") try? AppDB?.insertDNSQuery("\(arc4random() % 5).count.test.com", blocked: true) } } diff --git a/main/DB/SQDB.swift b/main/DB/SQDB.swift index 8b317c1..a463b4d 100644 --- a/main/DB/SQDB.swift +++ b/main/DB/SQDB.swift @@ -25,12 +25,10 @@ enum SQLiteError: Error { // MARK: - SQLiteDatabase -var AppDB: SQLiteDatabase? { get { try? SQLiteDatabase.open() } } - class SQLiteDatabase { private let dbPointer: OpaquePointer? private init(dbPointer: OpaquePointer?) { -// print("SQLite path: \(basePath!.absoluteString)") +// print("SQLite path: \(URL.internalDB())") self.dbPointer = dbPointer } @@ -133,18 +131,15 @@ private extension SQLiteDatabase { sqlite3_bind_text(stmt, col, (value as NSString).utf8String, -1, nil) == SQLITE_OK } + func bindTextOrNil(_ stmt: OpaquePointer, _ col: Int32, _ value: String?) -> Bool { + sqlite3_bind_text(stmt, col, (value == nil) ? nil : (value! as NSString).utf8String, -1, nil) == SQLITE_OK + } + func readText(_ stmt: OpaquePointer, _ col: Int32) -> String? { let val = sqlite3_column_text(stmt, col) return (val != nil ? String(cString: val!) : nil) } - func readGroupedDomain(_ stmt: OpaquePointer) -> GroupedDomain { - GroupedDomain(domain: readText(stmt, 0) ?? "", - total: sqlite3_column_int(stmt, 1), - blocked: sqlite3_column_int(stmt, 2), - lastModified: sqlite3_column_int64(stmt, 3)) - } - func allRows(_ stmt: OpaquePointer, _ fn: (OpaquePointer) -> T) -> [T] { var r: [T] = [] while (sqlite3_step(stmt) == SQLITE_ROW) { r.append(fn(stmt)) } @@ -162,6 +157,7 @@ extension SQLiteDatabase { func initScheme() { try? self.createTable(table: DNSQueryT.self) try? self.createTable(table: DNSFilterT.self) + try? self.createTable(table: Recording.self) } } @@ -217,6 +213,13 @@ extension SQLiteDatabase { // MARK: read + func readGroupedDomain(_ stmt: OpaquePointer) -> GroupedDomain { + GroupedDomain(domain: readText(stmt, 0) ?? "", + total: sqlite3_column_int(stmt, 1), + blocked: sqlite3_column_int(stmt, 2), + lastModified: sqlite3_column_int64(stmt, 3)) + } + func domainList(since ts: Timestamp = 0) -> [GroupedDomain]? { try? run(sql: "SELECT domain, COUNT(*), SUM(logOpt&1), MAX(ts) FROM req \(ts == 0 ? "" : "WHERE ts > ?") GROUP BY domain ORDER BY 4 DESC;", bind: { ts == 0 || self.bindInt64($0, 1, ts) @@ -302,3 +305,87 @@ extension SQLiteDatabase { do { try createFilter() } catch { updateFilter() } } } + + +// MARK: - Recordings + +struct Recording: SQLTable { + let start: Timestamp + let stop: Timestamp? + var appId: String? = nil + var title: String? = nil + var notes: String? = nil + static var createStatement: String { + return """ + CREATE TABLE IF NOT EXISTS rec( + start BIGINT DEFAULT (strftime('%s','now')), + stop BIGINT, + appid VARCHAR(255), + title VARCHAR(255), + notes TEXT + ); + """ + } +} + +extension SQLiteDatabase { + + // MARK: write + + func startNewRecording(_ title: String? = nil, appBundle: String? = nil) throws -> Recording { + try run(sql: "INSERT INTO rec (title, appid) VALUES (?, ?);", bind: { + self.bindTextOrNil($0, 1, title) && self.bindTextOrNil($0, 2, appBundle) + }) { stmt -> Recording in + try ifStep(stmt, SQLITE_DONE) + return ongoingRecording()! + } + } + + func stopRecordings() { + try? run(sql: "UPDATE rec SET stop = (strftime('%s','now')) WHERE stop IS NULL;", bind: nil) { stmt -> Void in + sqlite3_step(stmt) + } + } + + func updateRecording(_ r: Recording) { + try? run(sql: "UPDATE rec SET title = ?, appid = ?, notes = ? WHERE start = ? LIMIT 1;", bind: { + self.bindTextOrNil($0, 1, r.title) && self.bindTextOrNil($0, 2, r.appId) + && self.bindTextOrNil($0, 3, r.notes) && self.bindInt64($0, 4, r.start) + }) { stmt -> Void in + sqlite3_step(stmt) + } + } + + func deleteRecording(_ r: Recording) throws -> Bool { + try run(sql: "DELETE FROM rec WHERE start = ? LIMIT 1;", bind: { + self.bindInt64($0, 1, r.start) + }) { + try ifStep($0, SQLITE_DONE) + return sqlite3_changes(dbPointer) > 0 + } + } + + // MARK: read + + func readRecording(_ stmt: OpaquePointer) -> Recording { + let end = sqlite3_column_int64(stmt, 1) + return Recording(start: sqlite3_column_int64(stmt, 0), + stop: end == 0 ? nil : end, + appId: readText(stmt, 2), + title: readText(stmt, 3), + notes: readText(stmt, 4)) + } + + func ongoingRecording() -> Recording? { + try? run(sql: "SELECT * FROM rec WHERE stop IS NULL LIMIT 1;", bind: nil) { + try ifStep($0, SQLITE_ROW) + return readRecording($0) + } + } + + func allRecordings() -> [Recording]? { + try? run(sql: "SELECT * FROM rec WHERE stop IS NOT NULL;", bind: nil) { + allRows($0) { readRecording($0) } + } + } +} diff --git a/main/Extensions/AlertSheet.swift b/main/Extensions/AlertSheet.swift index 2bbdce4..570d3db 100644 --- a/main/Extensions/AlertSheet.swift +++ b/main/Extensions/AlertSheet.swift @@ -1,63 +1,67 @@ import UIKit -// MARK: Basic Alerts - -/// - Parameters: -/// - buttonText: Default: "Dismiss" -func Alert(title: String?, text: String?, buttonText: String = "Dismiss") -> UIAlertController { - let alert = UIAlertController(title: title, message: text, preferredStyle: .alert) - alert.addAction(UIAlertAction(title: buttonText, style: .cancel, handler: nil)) - return alert -} - -/// - Parameters: -/// - buttonText: Default: "Dismiss" -func ErrorAlert(_ error: Error, buttonText: String = "Dismiss") -> UIAlertController { - return Alert(title: "Error", text: error.localizedDescription, buttonText: buttonText) -} - -/// - Parameters: -/// - buttonText: Default: "Dismiss" -func ErrorAlert(_ errorDescription: String, buttonText: String = "Dismiss") -> UIAlertController { - return Alert(title: "Error", text: errorDescription, buttonText: buttonText) -} - -/// - Parameters: -/// - buttonText: Default: "Continue" -/// - buttonStyle: Default: `.default` -func AskAlert(title: String?, text: String?, buttonText: String = "Continue", buttonStyle: UIAlertAction.Style = .default, action: @escaping (UIAlertController) -> Void) -> UIAlertController { - let alert = Alert(title: title, text: text, buttonText: "Cancel") - alert.addAction(UIAlertAction(title: buttonText, style: buttonStyle) { _ in action(alert) }) - return alert -} - extension UIAlertController { func presentIn(_ viewController: UIViewController?) { viewController?.present(self, animated: true, completion: nil) } } +// MARK: Basic Alerts + +/// - Parameters: +/// - buttonText: Default: `"Dismiss"` +func Alert(title: String?, text: String?, buttonText: String = "Dismiss") -> UIAlertController { + let alert = UIAlertController(title: title, message: text, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: buttonText, style: .cancel, handler: nil)) + return alert +} + +/// - Parameters: +/// - buttonText: Default:`"Dismiss"` +func ErrorAlert(_ error: Error, buttonText: String = "Dismiss") -> UIAlertController { + return Alert(title: "Error", text: error.localizedDescription, buttonText: buttonText) +} + +/// - Parameters: +/// - buttonText: Default: `"Dismiss"` +func ErrorAlert(_ errorDescription: String, buttonText: String = "Dismiss") -> UIAlertController { + return Alert(title: "Error", text: errorDescription, buttonText: buttonText) +} + +/// - Parameters: +/// - buttonText: Default: `"Continue"` +/// - buttonStyle: Default: `.default` +func AskAlert(title: String?, text: String?, buttonText: String = "Continue", buttonStyle: UIAlertAction.Style = .default, action: @escaping (UIAlertController) -> Void) -> UIAlertController { + let alert = Alert(title: title, text: text, buttonText: "Cancel") + alert.addAction(UIAlertAction(title: buttonText, style: buttonStyle) { _ in action(alert) }) + return alert +} + // MARK: Alert with multiple options -func AlertWithOptions(title: String?, text: String?, buttons: [String], lastIsDestructive: Bool = false, callback: @escaping (_ index: Int?) -> Void) -> UIAlertController { +/// - Parameters: +/// - buttons: Default: `[]` +/// - lastIsDestructive: Default: `false` +/// - cancelButtonText: Default: `"Dismiss"` +func BottomAlert(title: String?, text: String?, buttons: [String] = [], lastIsDestructive: Bool = false, cancelButtonText: String = "Dismiss", callback: @escaping (_ index: Int?) -> Void) -> UIAlertController { let alert = UIAlertController(title: title, message: text, preferredStyle: .actionSheet) for (i, btn) in buttons.enumerated() { let dangerous = (lastIsDestructive && i + 1 == buttons.count) alert.addAction(UIAlertAction(title: btn, style: dangerous ? .destructive : .default) { _ in callback(i) }) } - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in callback(nil) }) + alert.addAction(UIAlertAction(title: cancelButtonText, style: .cancel) { _ in callback(nil) }) return alert } func AlertDeleteLogs(_ domain: String, latest: Timestamp, success: @escaping (_ tsMin: Timestamp) -> Void) -> UIAlertController { - let sinceNow = TimestampNow() - latest + let sinceNow = Timestamp.now() - latest var buttons = ["Last 5 minutes", "Last 15 minutes", "Last hour", "Last 24 hours", "Delete everything"] var times: [Timestamp] = [300, 900, 3600, 86400] while times.count > 0, times[0] < sinceNow { buttons.removeFirst() times.removeFirst() } - return AlertWithOptions(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: buttons, lastIsDestructive: true) { + return BottomAlert(title: "Delete logs", text: "Delete logs for domain '\(domain)'", buttons: buttons, lastIsDestructive: true, cancelButtonText: "Cancel") { guard let idx = $0 else { return } diff --git a/main/Extensions/GroupedDomain.swift b/main/Extensions/DBExtensions.swift similarity index 50% rename from main/Extensions/GroupedDomain.swift rename to main/Extensions/DBExtensions.swift index 9042bd1..04d743f 100644 --- a/main/Extensions/GroupedDomain.swift +++ b/main/Extensions/DBExtensions.swift @@ -18,3 +18,19 @@ extension Array where Element == GroupedDomain { return GroupedDomain(domain: domain, total: t, blocked: b, lastModified: m, options: opt) } } + +extension Recording { + func stoppedCopy() -> Recording { + stop != nil ? self : Recording(start: start, stop: Timestamp(Date().timeIntervalSince1970), + appId: appId, title: title, notes: notes) + } + var fallbackTitle: String { get { "Unnamed #\(start)" } } + var duration: Timestamp? { get { stop == nil ? nil : stop! - start } } + var durationString: String? { get { stop == nil ? nil : TimeFormat.from(duration!) } } +} + +extension Timestamp { + func asDateTime() -> String { dateTimeFormat.string(from: self) } + func toDate() -> Date { Date(timeIntervalSince1970: Double(self)) } + static func now() -> Timestamp { Timestamp(Date().timeIntervalSince1970) } +} diff --git a/main/Extensions/Generic.swift b/main/Extensions/Generic.swift index 226c86b..71886d1 100644 --- a/main/Extensions/Generic.swift +++ b/main/Extensions/Generic.swift @@ -84,4 +84,20 @@ extension DateFormatter { } } -func TimestampNow() -> Timestamp { Timestamp(Date().timeIntervalSince1970) } +struct TimeFormat { + static func from(_ duration: Timestamp) -> String { + String(format: "%02d:%02d", duration / 60, duration % 60) + } + static func from(_ duration: TimeInterval, millis: Bool = false) -> String { + let t = Int(duration) + if millis { + let mil = Int(duration * 1000) % 1000 + return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil) + } + return String(format: "%02d:%02d", t / 60, t % 60) + } + static func since(_ date: Date, millis: Bool = false) -> String { + from(Date().timeIntervalSince(date), millis: millis) + } + +} diff --git a/main/Extensions/Notifications.swift b/main/Extensions/Notifications.swift index dda4929..037bc5a 100644 --- a/main/Extensions/Notifications.swift +++ b/main/Extensions/Notifications.swift @@ -3,6 +3,7 @@ import Foundation let NotifyVPNStateChanged = NSNotification.Name("GlassVPNStateChanged") // VPNState! let NotifyFilterChanged = NSNotification.Name("PSIFilterSettingsChanged") // nil! let NotifyLogHistoryReset = NSNotification.Name("PSILogHistoryReset") // nil! +let NotifyRecordingChanged = NSNotification.Name("PSIRecordingChanged") // (Recording, deleted: Bool)! extension NSNotification.Name { diff --git a/main/Extensions/TableView.swift b/main/Extensions/TableView.swift index 757e206..3c03bf7 100644 --- a/main/Extensions/TableView.swift +++ b/main/Extensions/TableView.swift @@ -3,8 +3,8 @@ import UIKit extension GroupedDomain { var detailCellText: String { get { return blocked > 0 - ? "\(dateTimeFormat.string(from: lastModified)) — \(blocked)/\(total) blocked" - : "\(dateTimeFormat.string(from: lastModified)) — \(total)" + ? "\(lastModified.asDateTime()) — \(blocked)/\(total) blocked" + : "\(lastModified.asDateTime()) — \(total)" } } } @@ -59,29 +59,29 @@ extension IncrementalDataSourceUpdate { func insertRow(_ obj: GroupedDomain, at index: Int) { dataSource.insert(obj, at: index) ifDisplayed { - self.tableView.insertRows(at: [IndexPath(row: index, section: 0)], with: .left) + self.tableView.insertRows(at: [IndexPath(row: index)], with: .left) } } func moveRow(_ obj: GroupedDomain, from: Int, to: Int) { dataSource.remove(at: from) dataSource.insert(obj, at: to) ifDisplayed { - let source = IndexPath(row: from, section: 0) + let source = IndexPath(row: from) let cell = self.tableView.cellForRow(at: source) cell?.detailTextLabel?.text = obj.detailCellText - self.tableView.moveRow(at: source, to: IndexPath(row: to, section: 0)) + self.tableView.moveRow(at: source, to: IndexPath(row: to)) } } func replaceRow(_ obj: GroupedDomain, at index: Int) { dataSource[index] = obj ifDisplayed { - self.tableView.reloadRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + self.tableView.reloadRows(at: [IndexPath(row: index)], with: .automatic) } } func deleteRow(at index: Int) { dataSource.remove(at: index) ifDisplayed { - self.tableView.deleteRows(at: [IndexPath(row: index, section: 0)], with: .automatic) + self.tableView.deleteRows(at: [IndexPath(row: index)], with: .automatic) } } func replaceData(with newData: [GroupedDomain]) { @@ -91,3 +91,8 @@ extension IncrementalDataSourceUpdate { } } } + +extension IndexPath { + /// Convenience init with `section: 0` + public init(row: Int) { self.init(row: row, section: 0) } +} diff --git a/main/Recordings/TVCPreviousRecords.swift b/main/Recordings/TVCPreviousRecords.swift new file mode 100644 index 0000000..de2e257 --- /dev/null +++ b/main/Recordings/TVCPreviousRecords.swift @@ -0,0 +1,74 @@ +import UIKit + +class TVCPreviousRecords: UITableViewController { + private var dataSource: [Recording] = [] + + override func viewDidLoad() { + dataSource = DBWrp.listOfRecordings().reversed() // newest on top + NotifyRecordingChanged.observe(call: #selector(recordingDidChange(_:)), on: self) + } + + func stopRecording(_ record: Recording?) { + guard let r = record?.stoppedCopy() else { + return + } + insertNewRecord(r) + editRecord(r, isNewRecording: true) + } + + @objc private func recordingDidChange(_ notification: Notification) { + let (new, deleted) = notification.object as! (Recording, Bool) + if let i = dataSource.firstIndex(where: { $0.start == new.start }) { + if deleted { + dataSource.remove(at: i) + tableView.deleteRows(at: [IndexPath(row: i)], with: .automatic) + } else { + dataSource[i] = new + tableView.reloadRows(at: [IndexPath(row: i)], with: .automatic) + } + } else if !deleted { + insertNewRecord(new) + } + } + + private func insertNewRecord(_ record: Recording) { + dataSource.insert(record, at: 0) + tableView.insertRows(at: [IndexPath(row: 0)], with: .top) + } + + + // MARK: - Table View Delegate + + override func tableView(_ tableView: UITableView, accessoryButtonTappedForRowWith indexPath: IndexPath) { + editRecord(dataSource[indexPath.row]) + } + + private func editRecord(_ record: Recording, isNewRecording: Bool = false) { + performSegue(withIdentifier: "editRecordSegue", sender: (record, isNewRecording)) + } + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { + if segue.identifier == "editRecordSegue" { + let (record, newlyCreated) = sender as! (Recording, Bool) + let target = segue.destination as! VCEditRecording + target.record = record + target.deleteOnCancel = newlyCreated + } + } + + + // MARK: - Table View Data Source + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + dataSource.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "PreviousRecordCell")! + let x = dataSource[indexPath.row] + cell.textLabel?.text = x.title ?? x.fallbackTitle + cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil + cell.detailTextLabel?.text = "at \(x.start.asDateTime()), duration: \(x.durationString ?? "?")" + return cell + } +} diff --git a/main/Recordings/VCEditRecording.swift b/main/Recordings/VCEditRecording.swift new file mode 100644 index 0000000..660db2d --- /dev/null +++ b/main/Recordings/VCEditRecording.swift @@ -0,0 +1,68 @@ +import UIKit + +class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate { + var record: Recording! + var deleteOnCancel: Bool = false + + @IBOutlet private var buttonCancel: UIBarButtonItem! + @IBOutlet private var buttonSave: UIBarButtonItem! + @IBOutlet private var inputTitle: UITextField! + @IBOutlet private var inputNotes: UITextView! + @IBOutlet private var inputDetails: UITextView! + + override func viewDidLoad() { + if deleteOnCancel { // mark as destructive + buttonCancel.tintColor = .systemRed + } + inputTitle.placeholder = record.fallbackTitle + inputTitle.text = record.title + inputNotes.text = record.notes + inputDetails.text = """ + Start:\t\t\(record.start.asDateTime()) + End:\t\t\(record.stop?.asDateTime() ?? "?") + Duration:\t\(record.durationString ?? "?") + """ + } + + func textFieldDidChangeSelection(_ _: UITextField) { validateInput() } + func textViewDidChange(_ _: UITextView) { validateInput() } + + private func validateInput() { + let changed = (inputTitle.text != record.title ?? "" || inputNotes.text != record.notes ?? "") + buttonSave.isEnabled = changed + } + + @IBAction func didTapSave(_ sender: UIBarButtonItem) { + if deleteOnCancel { // aka newly created + // if remains true, `viewDidDisappear` will delete the record + deleteOnCancel = false + // TODO: copy db entries in new table for editing + } + QLog.Debug("updating record \(record.start)") + record.title = (inputTitle.text == "") ? nil : inputTitle.text + record.notes = (inputNotes.text == "") ? nil : inputNotes.text + dismiss(animated: true) { + DBWrp.recordingUpdate(self.record) + } + } + + @IBAction func didTapCancel(_ sender: UIBarButtonItem) { + QLog.Debug("discard edit of record \(record.start)") + dismiss(animated: true) + } + + override func viewDidDisappear(_ animated: Bool) { + if deleteOnCancel { + QLog.Debug("deleting record \(record.start)") + DBWrp.recordingDelete(record) + deleteOnCancel = false + } + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == inputTitle { + return inputNotes.becomeFirstResponder() + } + return true + } +} diff --git a/main/Recordings/VCRecordings.swift b/main/Recordings/VCRecordings.swift new file mode 100644 index 0000000..7a01359 --- /dev/null +++ b/main/Recordings/VCRecordings.swift @@ -0,0 +1,73 @@ +import UIKit + +class VCRecordings: UIViewController { + private var currentRecording: Recording? + private var recordingTimer: Timer? + + @IBOutlet private var timeLabel: UILabel! + @IBOutlet private var startButton: UIButton! + + override func viewDidLoad() { + // Duplicate font attributes but set monospace + let traits = timeLabel.font.fontDescriptor.object(forKey: .traits) as? [UIFontDescriptor.TraitKey: Any] ?? [:] + let weight = traits[.weight] as? CGFloat ?? UIFont.Weight.regular.rawValue + timeLabel.font = UIFont.monospacedDigitSystemFont(ofSize: timeLabel.font.pointSize, weight: UIFont.Weight(rawValue: weight)) + // hide timer if not running + updateUI(setRecording: false, animated: false) + currentRecording = DBWrp.recordingGetCurrent() + } + + override func viewDidAppear(_ animated: Bool) { + if currentRecording != nil { startTimer(animate: false) } + } + + override func viewWillDisappear(_ animated: Bool) { + stopTimer(animate: false) + } + + @IBAction private func recordingButtonTapped(_ sender: UIButton) { + if recordingTimer == nil { + currentRecording = DBWrp.recordingStartNew() + startTimer(animate: true) + } else { + stopTimer(animate: true) + DBWrp.recordingStopAll() + (children.first as! TVCPreviousRecords).stopRecording(currentRecording!) + currentRecording = nil // otherwise it will restart + } + } + + private func startTimer(animate: Bool) { + guard let r = currentRecording, r.stop == nil else { + return + } + recordingTimer = Timer.repeating(0.173, call: #selector(timerCallback(_:)), on: self, userInfo: r.start.toDate()) + updateUI(setRecording: true, animated: animate) + } + + @objc private func timerCallback(_ sender: Timer) { + timeLabel.text = TimeFormat.since(sender.userInfo as! Date, millis: true) + } + + private func stopTimer(animate: Bool) { + recordingTimer?.invalidate() + recordingTimer = nil + updateUI(setRecording: false, animated: animate) + } + + private func updateUI(setRecording: Bool, animated: Bool) { + let title = setRecording ? "Stop Recording" : "Start New Recording" + let color = setRecording ? UIColor.systemRed : nil + let yT = setRecording ? 0 : -timeLabel.frame.height + let yB = (setRecording ? 1 : 0.5) * (startButton.superview!.frame.height - startButton.frame.height) + if !animated { // else title will flash + startButton.titleLabel?.text = title + } + UIView.animate(withDuration: animated ? 0.3 : 0) { + self.timeLabel.frame.origin.y = yT + self.startButton.frame.origin.y = yB + self.startButton.setTitle(title, for: .normal) + self.startButton.setTitleColor(color, for: .normal) + } + } +} diff --git a/main/Requests/TVCHostDetails.swift b/main/Requests/TVCHostDetails.swift index 20510b3..3abc58a 100644 --- a/main/Requests/TVCHostDetails.swift +++ b/main/Requests/TVCHostDetails.swift @@ -25,7 +25,7 @@ class TVCHostDetails: UITableViewController { override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "HostDetailCell")! let src = dataSource[indexPath.row] - cell.textLabel?.text = dateTimeFormat.string(from: src.ts) + cell.textLabel?.text = src.ts.asDateTime() cell.imageView?.image = (src.blocked ? UIImage(named: "shield-x") : nil) return cell }