Discard recording if time criteria not met
This commit is contained in:
@@ -23,10 +23,19 @@ enum PrefsShared {
|
|||||||
get { Int("AutoDeleteLogsDays") }
|
get { Int("AutoDeleteLogsDays") }
|
||||||
set { Int("AutoDeleteLogsDays", newValue) }
|
set { Int("AutoDeleteLogsDays", newValue) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
static var CurrentlyRecording: Bool {
|
|
||||||
get { Bool("CurrentlyRecording") }
|
|
||||||
set { Bool("CurrentlyRecording", newValue) }
|
// MARK: - Recording State
|
||||||
|
|
||||||
|
enum CurrentRecordingState : Int {
|
||||||
|
case Off = 0, App = 1, Background = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PrefsShared {
|
||||||
|
static var CurrentlyRecording: CurrentRecordingState {
|
||||||
|
get { CurrentRecordingState(rawValue: Int("CurrentlyRecording")) ?? .Off }
|
||||||
|
set { Int("CurrentlyRecording", newValue.rawValue) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ extension Recording {
|
|||||||
var fallbackTitle: String { get {
|
var fallbackTitle: String { get {
|
||||||
isLongTerm ? "Background Recording" : "Unnamed Recording #\(id)"
|
isLongTerm ? "Background Recording" : "Unnamed Recording #\(id)"
|
||||||
} }
|
} }
|
||||||
var duration: Timestamp? { get { stop == nil ? nil : stop! - start } }
|
var duration: Timestamp { get { stop ?? .now() - start } }
|
||||||
var isLongTerm: Bool { (duration ?? 0) > Timestamp.hours(1) }
|
var isLongTerm: Bool { duration > Timestamp.hours(1) }
|
||||||
var isShared: Bool { uploadkey?.count ?? 0 > 0}
|
var isShared: Bool { uploadkey?.count ?? 0 > 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,17 +86,21 @@ struct TimeFormat {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Duration string with format `mm:ss` or `mm:ss.SSS`
|
/// Duration string with format `mm:ss` or `mm:ss.SSS`
|
||||||
static func from(_ duration: TimeInterval, millis: Bool = false) -> String {
|
static func from(_ duration: TimeInterval, millis: Bool = false, hours: Bool = false) -> String {
|
||||||
let t = Int(duration)
|
let t = Int(duration)
|
||||||
|
let min = t / 60
|
||||||
|
let sec = t % 60
|
||||||
if millis {
|
if millis {
|
||||||
let mil = Int(duration * 1000) % 1000
|
let mil = Int(duration * 1000) % 1000
|
||||||
return String(format: "%02d:%02d.%03d", t / 60, t % 60, mil)
|
return String(format: "%02d:%02d.%03d", min, sec, mil)
|
||||||
|
} else if hours {
|
||||||
|
return String(format: "%02d:%02d:%02d", min / 60, min % 60, sec)
|
||||||
}
|
}
|
||||||
return String(format: "%02d:%02d", t / 60, t % 60)
|
return String(format: "%02d:%02d", min, sec)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Duration string with format `mm:ss` or `mm:ss.SSS` since reference date
|
/// Duration string with format `mm:ss` or `mm:ss.SSS` or `HH:mm:ss` since reference date
|
||||||
static func since(_ date: Date, millis: Bool = false) -> String {
|
static func since(_ date: Date, millis: Bool = false, hours: Bool = false) -> String {
|
||||||
from(Date().timeIntervalSince(date), millis: millis)
|
from(Date().timeIntervalSince(date), millis: millis, hours: hours)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
<rect key="frame" x="0.0" y="95" width="320" height="54.5"/>
|
<rect key="frame" x="0.0" y="95" width="320" height="54.5"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="00:00.000" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rbR-np-cXD">
|
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="00:00.000" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="rbR-np-cXD">
|
||||||
<rect key="frame" x="8" y="8" width="200" height="38"/>
|
<rect key="frame" x="8" y="8" width="200" height="38.5"/>
|
||||||
<accessibility key="accessibilityConfiguration">
|
<accessibility key="accessibilityConfiguration">
|
||||||
<accessibilityTraits key="traits" staticText="YES" updatesFrequently="YES"/>
|
<accessibilityTraits key="traits" staticText="YES" updatesFrequently="YES"/>
|
||||||
</accessibility>
|
</accessibility>
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
<nil key="highlightedColor"/>
|
<nil key="highlightedColor"/>
|
||||||
</label>
|
</label>
|
||||||
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vAq-EZ-Gmx">
|
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="vAq-EZ-Gmx">
|
||||||
<rect key="frame" x="212" y="8" width="100" height="38"/>
|
<rect key="frame" x="212" y="8" width="100" height="38.5"/>
|
||||||
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
|
<fontDescription key="fontDescription" style="UICTFontTextStyleTitle2"/>
|
||||||
<state key="normal" title="Stop">
|
<state key="normal" title="Stop">
|
||||||
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="titleColor" systemColor="systemRedColor" red="1" green="0.23137254900000001" blue="0.18823529410000001" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
<outlet property="buttonView" destination="La3-9e-6TK" id="UMg-xx-6OV"/>
|
<outlet property="buttonView" destination="La3-9e-6TK" id="UMg-xx-6OV"/>
|
||||||
<outlet property="headerView" destination="ppJ-js-Wwz" id="68u-8M-R2Q"/>
|
<outlet property="headerView" destination="ppJ-js-Wwz" id="68u-8M-R2Q"/>
|
||||||
<outlet property="runningView" destination="9Yj-FX-eFd" id="L2C-YR-2HN"/>
|
<outlet property="runningView" destination="9Yj-FX-eFd" id="L2C-YR-2HN"/>
|
||||||
|
<outlet property="stopButton" destination="vAq-EZ-Gmx" id="XiW-1H-I9y"/>
|
||||||
<outlet property="timeLabel" destination="rbR-np-cXD" id="EEe-8F-HT6"/>
|
<outlet property="timeLabel" destination="rbR-np-cXD" id="EEe-8F-HT6"/>
|
||||||
</connections>
|
</connections>
|
||||||
</viewController>
|
</viewController>
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ struct VPNAppMessage {
|
|||||||
.init("notify-prefs-change:1")
|
.init("notify-prefs-change:1")
|
||||||
}
|
}
|
||||||
/// Triggered whenever user taps on the start/stop recording button
|
/// Triggered whenever user taps on the start/stop recording button
|
||||||
static func isRecording(_ state: Bool) -> Self {
|
static func isRecording(_ state: CurrentRecordingState) -> Self {
|
||||||
.init("recording-now:\(state ? 1 : 0)")
|
.init("recording-now:\(state.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class GlassVPNHook {
|
|||||||
reloadDomainFilter()
|
reloadDomainFilter()
|
||||||
setAutoDelete(PrefsShared.AutoDeleteLogsDays)
|
setAutoDelete(PrefsShared.AutoDeleteLogsDays)
|
||||||
cachedNotify = CachedConnectionAlert()
|
cachedNotify = CachedConnectionAlert()
|
||||||
currentlyRecording = PrefsShared.CurrentlyRecording
|
currentlyRecording = PrefsShared.CurrentlyRecording != .Off
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Invalidate auto-delete timer and release stored properties. You should nullify this instance afterwards.
|
/// Invalidate auto-delete timer and release stored properties. You should nullify this instance afterwards.
|
||||||
@@ -47,7 +47,8 @@ class GlassVPNHook {
|
|||||||
cachedNotify = CachedConnectionAlert()
|
cachedNotify = CachedConnectionAlert()
|
||||||
return
|
return
|
||||||
case "recording-now":
|
case "recording-now":
|
||||||
currentlyRecording = value == "1"
|
let newState = CurrentRecordingState(rawValue: Int(value) ?? 0)
|
||||||
|
currentlyRecording = newState != .Off
|
||||||
return
|
return
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class TVCPreviousRecords: UITableViewController, EditActionsRemove {
|
|||||||
let x = dataSource[indexPath.row]
|
let x = dataSource[indexPath.row]
|
||||||
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
cell.textLabel?.text = x.title ?? x.fallbackTitle
|
||||||
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
cell.textLabel?.textColor = (x.title == nil) ? .systemGray : nil
|
||||||
cell.detailTextLabel?.text = "\(x.isShared ? "✅ " : "")at \(DateFormat.minutes(x.start)), duration: \(TimeFormat.from(x.duration ?? 0))"
|
cell.detailTextLabel?.text = "\(x.isShared ? "✅ " : "")at \(DateFormat.minutes(x.start)), duration: \(TimeFormat.from(x.duration))"
|
||||||
cell.imageView?.image = x.isLongTerm ? nil : BundleIcon.image(x.appId)
|
cell.imageView?.image = x.isLongTerm ? nil : BundleIcon.image(x.appId)
|
||||||
cell.imageView?.layer.cornerRadius = 6.75
|
cell.imageView?.layer.cornerRadius = 6.75
|
||||||
cell.imageView?.layer.masksToBounds = true
|
cell.imageView?.layer.masksToBounds = true
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class VCEditRecording: UIViewController, UITextFieldDelegate, UITextViewDelegate
|
|||||||
inputDetails.text = """
|
inputDetails.text = """
|
||||||
Start: \(DateFormat.seconds(record.start))
|
Start: \(DateFormat.seconds(record.start))
|
||||||
End: \(record.stop == nil ? "?" : DateFormat.seconds(record.stop!))
|
End: \(record.stop == nil ? "?" : DateFormat.seconds(record.stop!))
|
||||||
Duration: \(TimeFormat.from(record.duration ?? 0))
|
Duration: \(TimeFormat.from(record.duration))
|
||||||
"""
|
"""
|
||||||
validateSaveButton()
|
validateSaveButton()
|
||||||
if deleteOnCancel { // mark as destructive
|
if deleteOnCancel { // mark as destructive
|
||||||
|
|||||||
@@ -3,17 +3,22 @@ import UIKit
|
|||||||
class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
||||||
private var currentRecording: Recording?
|
private var currentRecording: Recording?
|
||||||
private var recordingTimer: Timer?
|
private var recordingTimer: Timer?
|
||||||
|
private var state: CurrentRecordingState = .Off
|
||||||
|
|
||||||
@IBOutlet private var headerView: UIView!
|
@IBOutlet private var headerView: UIView!
|
||||||
@IBOutlet private var buttonView: UIView!
|
@IBOutlet private var buttonView: UIView!
|
||||||
@IBOutlet private var runningView: UIView!
|
@IBOutlet private var runningView: UIView!
|
||||||
@IBOutlet private var timeLabel: UILabel!
|
@IBOutlet private var timeLabel: UILabel!
|
||||||
|
@IBOutlet private var stopButton: UIButton!
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
timeLabel.font = timeLabel.font.monoSpace()
|
timeLabel.font = timeLabel.font.monoSpace()
|
||||||
if let ongoing = RecordingsDB.getCurrent() {
|
if let ongoing = RecordingsDB.getCurrent() {
|
||||||
currentRecording = ongoing
|
currentRecording = ongoing
|
||||||
startTimer(animate: false)
|
// Currently this class is the only one that changes the state,
|
||||||
|
// if that ever changes, make sure to update local state as well
|
||||||
|
state = PrefsShared.CurrentlyRecording
|
||||||
|
startTimer(animate: false, longterm: state == .Background)
|
||||||
} else { // hide timer if not running
|
} else { // hide timer if not running
|
||||||
updateUI(setRecording: false, animated: false)
|
updateUI(setRecording: false, animated: false)
|
||||||
}
|
}
|
||||||
@@ -50,44 +55,36 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
}
|
}
|
||||||
currentRecording = RecordingsDB.startNew()
|
currentRecording = RecordingsDB.startNew()
|
||||||
QLog.Debug("start recording #\(currentRecording!.id)")
|
QLog.Debug("start recording #\(currentRecording!.id)")
|
||||||
startTimer(animate: true)
|
let longterm = sender.selectedSegmentIndex == 1
|
||||||
notifyVPN(setRecording: true)
|
startTimer(animate: true, longterm: longterm)
|
||||||
|
notifyVPN(setRecording: longterm ? .Background : .App)
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction private func stopRecording(_ sender: UIButton) {
|
@IBAction private func stopRecording(_ sender: UIButton) {
|
||||||
notifyVPN(setRecording: false)
|
let validRecording = (state == .Background) == currentRecording!.isLongTerm
|
||||||
stopTimer(animate: true)
|
notifyVPN(setRecording: .Off) // will change state = .Off
|
||||||
RecordingsDB.stop(¤tRecording!)
|
stopTimer()
|
||||||
QLog.Debug("stop recording #\(currentRecording!.id)")
|
QLog.Debug("stop recording #\(currentRecording!.id)")
|
||||||
let editVC = (children.first as! TVCPreviousRecords)
|
RecordingsDB.stop(¤tRecording!)
|
||||||
editVC.insertAndEditRecording(currentRecording!)
|
if validRecording {
|
||||||
|
let editVC = (children.first as! TVCPreviousRecords)
|
||||||
|
editVC.insertAndEditRecording(currentRecording!)
|
||||||
|
} else {
|
||||||
|
QLog.Debug("Discard illegal recording #\(currentRecording!.id)")
|
||||||
|
RecordingsDB.delete(currentRecording!)
|
||||||
|
}
|
||||||
currentRecording = nil // otherwise it will restart
|
currentRecording = nil // otherwise it will restart
|
||||||
}
|
}
|
||||||
|
|
||||||
private func notifyVPN(setRecording state: Bool) {
|
private func notifyVPN(setRecording state: CurrentRecordingState) {
|
||||||
PrefsShared.CurrentlyRecording = state
|
PrefsShared.CurrentlyRecording = state
|
||||||
|
self.state = state
|
||||||
GlassVPN.send(.isRecording(state))
|
GlassVPN.send(.isRecording(state))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startTimer(animate: Bool) {
|
|
||||||
guard let r = currentRecording, r.stop == nil else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
recordingTimer = Timer.repeating(0.086, call: #selector(timerCallback(_:)), on: self, userInfo: Date(r.start))
|
|
||||||
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) {
|
private func updateUI(setRecording: Bool, animated: Bool) {
|
||||||
|
stopButton.tag = 99 // tag used in timerCallback()
|
||||||
|
stopButton.setTitle("", for: .normal) // prevent flashing while animating in and out
|
||||||
let block = {
|
let block = {
|
||||||
self.headerView.isHidden = setRecording
|
self.headerView.isHidden = setRecording
|
||||||
self.buttonView.isHidden = setRecording
|
self.buttonView.isHidden = setRecording
|
||||||
@@ -96,6 +93,34 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
animated ? UIView.animate(withDuration: 0.3, animations: block) : block()
|
animated ? UIView.animate(withDuration: 0.3, animations: block) : block()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startTimer(animate: Bool, longterm: Bool) {
|
||||||
|
guard let r = currentRecording, r.stop == nil else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateUI(setRecording: true, animated: animate)
|
||||||
|
let freq = longterm ? 1 : 0.086
|
||||||
|
let obj = (longterm, Date(r.start))
|
||||||
|
recordingTimer = Timer.repeating(freq, call: #selector(timerCallback(_:)), on: self, userInfo: obj)
|
||||||
|
recordingTimer!.fire() // update label immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopTimer() {
|
||||||
|
recordingTimer?.invalidate()
|
||||||
|
recordingTimer = nil
|
||||||
|
updateUI(setRecording: false, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func timerCallback(_ sender: Timer) {
|
||||||
|
let (slow, start) = sender.userInfo as! (Bool, Date)
|
||||||
|
timeLabel.text = TimeFormat.since(start, millis: !slow, hours: slow)
|
||||||
|
let valid = slow == currentRecording!.isLongTerm
|
||||||
|
let validInt = (valid ? 1 : 0)
|
||||||
|
if stopButton.tag != validInt {
|
||||||
|
stopButton.tag = validInt
|
||||||
|
stopButton.setTitle(valid ? "Stop" : slow ? "Cancel" : "Discard", for: .normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// MARK: Tutorial View Controller
|
// MARK: Tutorial View Controller
|
||||||
|
|
||||||
@@ -103,13 +128,21 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
let x = TutorialSheet()
|
let x = TutorialSheet()
|
||||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||||
.h1("How to record?\n")
|
.h1("How to record?\n")
|
||||||
.normal("\nBefore you begin a new recording make sure that you quit all running applications. " +
|
.normal("\nThere are two types: specific app recordings and general background activity. " +
|
||||||
"Tap on the 'Start Recording' button and switch to the application you'd like to inspect. " +
|
"The former are usually 3 – 5 minutes long, the latter need to be at least an hour long.")
|
||||||
"Use the App as you would normally. Try to get to all corners and functionality the App provides. " +
|
.h2("\n\nApp recording\n")
|
||||||
"When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording." +
|
.normal("Before you begin make sure that you quit all running applications and wait a few seconds. " +
|
||||||
"\n\n" +
|
"Tap on the 'App' recording button and switch to the application you'd like to inspect. " +
|
||||||
"Upon completion you will find your recording in the section below. " +
|
"Use the App as you would normally. Try to get to all corners and functionality the App provides. " +
|
||||||
"You can review your results and remove user specific information if necessary.")
|
"When you feel that you have captured enough content, come back to ").italic("AppCheck").normal(" and stop the recording.")
|
||||||
|
.h2("\n\nBackground recording\n")
|
||||||
|
.normal("Will answer one simple question: What communications happen while you aren't using your device. " +
|
||||||
|
"You should solely start a background recording when you know you aren't going to use your device in the near future. " +
|
||||||
|
"For example, before you go to bed.\n" +
|
||||||
|
"As soon as you start using your device, you should stop the recording to avoid distorting the results.")
|
||||||
|
.h2("\n\nFinish\n")
|
||||||
|
.normal("Upon completion you will find your recording in the section below. " +
|
||||||
|
"You can review your results and remove any user specific information if necessary.\n")
|
||||||
))
|
))
|
||||||
x.buttonTitleDone = "Close"
|
x.buttonTitleDone = "Close"
|
||||||
x.present()
|
x.present()
|
||||||
@@ -120,10 +153,10 @@ class VCRecordings: UIViewController, UINavigationControllerDelegate {
|
|||||||
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
x.addSheet().addArrangedSubview(QuickUI.text(attributed: NSMutableAttributedString()
|
||||||
.h1("What are Recordings?\n")
|
.h1("What are Recordings?\n")
|
||||||
.normal("\nSimilar to the default logging, recordings will intercept every request and log it for later review. " +
|
.normal("\nSimilar to the default logging, recordings will intercept every request and log it for later review. " +
|
||||||
"Recordings are usually 3 – 5 minutes long and cover a single application. " +
|
"App recordings are usually 3 – 5 minutes long and cover a single application. " +
|
||||||
"You can utilize recordings for App analysis or to get a ground truth for background traffic." +
|
"You can utilize recordings for App analysis or to get a ground truth on background traffic." +
|
||||||
"\n\n" +
|
"\n\n" +
|
||||||
"Optionally, you can help us by providing app specific recordings. " +
|
"Optionally, you can help us by providing your app specific recordings. " +
|
||||||
"Together with your findings we can create a community driven privacy monitor. " +
|
"Together with your findings we can create a community driven privacy monitor. " +
|
||||||
"The research results will help you and others avoid Apps that unnecessarily share data with third-party providers.")
|
"The research results will help you and others avoid Apps that unnecessarily share data with third-party providers.")
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class VCShareRecording : UIViewController {
|
|||||||
let start = record.start
|
let start = record.start
|
||||||
let comp = Calendar.current.dateComponents([.weekOfYear, .yearForWeekOfYear], from: Date(start))
|
let comp = Calendar.current.dateComponents([.weekOfYear, .yearForWeekOfYear], from: Date(start))
|
||||||
let wkYear = "\(comp.yearForWeekOfYear ?? 0).\(comp.weekOfYear ?? 0)"
|
let wkYear = "\(comp.yearForWeekOfYear ?? 0).\(comp.weekOfYear ?? 0)"
|
||||||
let lenSec = record.duration ?? 0
|
let lenSec = record.duration
|
||||||
|
|
||||||
let res = RecordingsDB.details(record)
|
let res = RecordingsDB.details(record)
|
||||||
var cluster: [String : [Timestamp]] = [:]
|
var cluster: [String : [Timestamp]] = [:]
|
||||||
|
|||||||
Reference in New Issue
Block a user