diff --git a/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift b/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift new file mode 100644 index 000000000..caa417be9 --- /dev/null +++ b/Dayflow/Dayflow/Core/Recording/IdleCaptureInterval.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Defaults for idle-throttled screenshot capture (issue #286). +/// +/// The throttled interval must stay at or under IdleBatchClassifier's +/// maxAllowedUncoveredGapSeconds (30s), not just under AnalysisManager's +/// looser batch maxGap (2 min). idleSecondsAtCapture measures time since +/// the *last input event*, not time since the last screenshot — so a brief +/// activity blip mid-idle-stretch resets that clock to near-zero for the +/// next screenshot, leaving up to (throttledIntervalSeconds - 1) seconds of +/// the *previous* inter-screenshot gap uncovered. At 30s that worst case is +/// exactly 29s, just inside the 30s limit; anything higher can silently +/// exceed it and kick the whole batch out of idle classification, forcing +/// full LLM processing — the opposite of what this throttle is for. See +/// IdleCaptureIntervalTests.testWorstCaseBlipDuringIdleStaysWithinClassifierGapLimit. +enum IdleCaptureDefaults { + static let idleThresholdSeconds: TimeInterval = 120 + static let throttledIntervalSeconds: TimeInterval = 30 +} + +/// User preference for idle-throttled capture. Only an on/off toggle is +/// exposed — the threshold/interval values above are tuned constants, not +/// something a typical user needs to hand-configure (matching IdleBatchRules' +/// hardcoded-constants convention elsewhere in this pipeline). +enum IdleCapturePreferences { + static let enabledKey = "idleCaptureThrottleEnabled" + + static var enabled: Bool { + get { + UserDefaults.standard.object(forKey: enabledKey) as? Bool ?? true + } + set { + UserDefaults.standard.set(newValue, forKey: enabledKey) + } + } +} + +/// Pure decision logic for how often to capture a screenshot given how long +/// the user has been idle. Kept free of ScreenRecorder's actors/queues so it +/// can be unit-tested directly. +enum IdleCaptureInterval { + static func interval( + baseInterval: TimeInterval, + idleThrottledInterval: TimeInterval, + idleSeconds: Int?, + idleThresholdSeconds: TimeInterval + ) -> TimeInterval { + guard let idleSeconds, TimeInterval(idleSeconds) >= idleThresholdSeconds else { + return baseInterval + } + return idleThrottledInterval + } +} diff --git a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift index 292ea8cf9..713e719d6 100644 --- a/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift +++ b/Dayflow/Dayflow/Core/Recording/ScreenRecorder.swift @@ -35,6 +35,20 @@ private enum Config { static var screenshotInterval: TimeInterval { ScreenshotConfig.interval } + + /// Effective capture interval given current idle state. Throttles (rather + /// than pauses) capture during long idle periods, per issue #286 — a hard + /// pause would starve IdleBatchClassifier of the screenshots it needs to + /// produce an "Idle" timeline card, turning idle periods into silent gaps. + static func effectiveScreenshotInterval(idleSeconds: Int?) -> TimeInterval { + guard IdleCapturePreferences.enabled else { return screenshotInterval } + return IdleCaptureInterval.interval( + baseInterval: screenshotInterval, + idleThrottledInterval: IdleCaptureDefaults.throttledIntervalSeconds, + idleSeconds: idleSeconds, + idleThresholdSeconds: IdleCaptureDefaults.idleThresholdSeconds + ) + } } private enum InputIdleSnapshot { @@ -150,6 +164,7 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { private let q = DispatchQueue(label: "com.dayflow.recorder", qos: .userInitiated) private var captureTimer: DispatchSourceTimer? + private var currentCaptureInterval: TimeInterval? private var sub: AnyCancellable? private var activeDisplaySub: AnyCancellable? private var state: RecorderState = .idle @@ -312,24 +327,47 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { // MARK: - Capture Timer - private func startCaptureTimer() { + private func startCaptureTimer(interval: TimeInterval? = nil) { stopCaptureTimer() - let interval = Config.screenshotInterval + let resolvedInterval = + interval ?? Config.effectiveScreenshotInterval(idleSeconds: InputIdleSnapshot.currentIdleSeconds()) + currentCaptureInterval = resolvedInterval + let timer = DispatchSource.makeTimerSource(queue: q) - timer.schedule(deadline: .now() + interval, repeating: interval) + timer.schedule(deadline: .now() + resolvedInterval, repeating: resolvedInterval) timer.setEventHandler { [weak self] in Task { await self?.captureScreenshot() } } timer.resume() captureTimer = timer - dbg("Capture timer started (interval: \(interval)s)") + dbg("Capture timer started (interval: \(resolvedInterval)s)") } private func stopCaptureTimer() { captureTimer?.cancel() captureTimer = nil + currentCaptureInterval = nil + } + + /// Re-checks the idle-throttled target interval and restarts the capture + /// timer if it has changed since it was last scheduled. Called on every + /// capture tick rather than driven by a separate poller, since a fixed + /// DispatchSourceTimer can't self-adjust its own repeat interval. The tick + /// that crosses the idle threshold still fires at the old cadence — the + /// new interval only takes effect starting from the next scheduled tick — + /// so there's a bounded, self-correcting one-tick lag in either direction. + /// Acceptable for a battery/disk optimization; not a correctness concern. + private func rescheduleCaptureTimerIfIntervalChanged() { + let idleSeconds = InputIdleSnapshot.currentIdleSeconds() + let targetInterval = Config.effectiveScreenshotInterval(idleSeconds: idleSeconds) + guard targetInterval != currentCaptureInterval else { return } + + dbg( + "Capture interval changing: \(String(describing: currentCaptureInterval))s → \(targetInterval)s (idle: \(String(describing: idleSeconds))s)" + ) + startCaptureTimer(interval: targetInterval) } // MARK: - Screenshot Capture @@ -339,6 +377,9 @@ final class ScreenRecorder: NSObject, @unchecked Sendable { dbg("captureScreenshot skipped - state: \(state.description)") return } + + rescheduleCaptureTimerIfIntervalChanged() + guard let display = cachedDisplay else { dbg("captureScreenshot skipped - no display") return diff --git a/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift b/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift index 124bb5f44..532632ea0 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/RecordingPrivacySettingsViewModel.swift @@ -6,9 +6,16 @@ final class RecordingPrivacySettingsViewModel: ObservableObject { @Published private(set) var installedApplications: [RecordingPrivacyApplication] = [] @Published private(set) var blockedIdentifiers: [String] @Published private(set) var isLoadingApplications = false + @Published var idleCaptureThrottleEnabled: Bool { + didSet { + guard idleCaptureThrottleEnabled != oldValue else { return } + IdleCapturePreferences.enabled = idleCaptureThrottleEnabled + } + } init() { blockedIdentifiers = RecordingPrivacyPreferences.blockedApplicationIdentifiers() + idleCaptureThrottleEnabled = IdleCapturePreferences.enabled } var filteredApplications: [RecordingPrivacyApplication] { diff --git a/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift b/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift index 7e6a2dc27..193a7702b 100644 --- a/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift +++ b/Dayflow/Dayflow/Views/UI/Settings/SettingsRecordingPrivacyTabView.swift @@ -11,15 +11,20 @@ struct SettingsRecordingPrivacyTabView: View { ] var body: some View { - SettingsSection( - title: "Recording privacy", - subtitle: "Choose apps Dayflow should hide from screenshots." - ) { - VStack(alignment: .leading, spacing: 18) { - searchField - installedAppsGrid - .frame(maxHeight: .infinity, alignment: .top) - blockedAppsTray + VStack(alignment: .leading, spacing: SettingsStyle.sectionSpacing) { + idleCaptureSection + + SettingsSection( + title: "Recording privacy", + subtitle: "Choose apps Dayflow should hide from screenshots." + ) { + VStack(alignment: .leading, spacing: 18) { + searchField + installedAppsGrid + .frame(maxHeight: .infinity, alignment: .top) + blockedAppsTray + } + .frame(maxHeight: .infinity, alignment: .topLeading) } .frame(maxHeight: .infinity, alignment: .topLeading) } @@ -29,6 +34,22 @@ struct SettingsRecordingPrivacyTabView: View { } } + private var idleCaptureSection: some View { + SettingsSection( + title: "Idle capture", + subtitle: "Reduce screenshot frequency while you're away from your computer." + ) { + SettingsRow( + label: "Slow down capture when idle", + subtitle: + "After 2 minutes without keyboard or mouse input, Dayflow takes screenshots less often to save disk space and battery.", + showsDivider: false + ) { + SettingsToggle(isOn: $viewModel.idleCaptureThrottleEnabled) + } + } + } + private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") diff --git a/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift b/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift new file mode 100644 index 000000000..91db9edfd --- /dev/null +++ b/Dayflow/DayflowTests/IdleCaptureIntervalTests.swift @@ -0,0 +1,154 @@ +import XCTest + +@testable import Dayflow + +final class IdleCaptureIntervalTests: XCTestCase { + func testActiveUserGetsBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 0, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + func testBelowThresholdStaysAtBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 119, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + func testAtThresholdSwitchesToThrottledInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 120, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 30) + } + + func testLongIdleStaysAtThrottledInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: 3600, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 30) + } + + func testNilIdleSecondsGetsBaseInterval() { + // No idle reading available (e.g. CGEventSource unavailable) — never throttle + // on missing data, since that could silently starve real activity capture. + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 30, + idleSeconds: nil, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } + + // MARK: - IdleBatchClassifier coverage-gap regression + + /// Mirrors IdleBatchClassifier.mergeCoverageSegments/invertedCoverageSegments + /// exactly, so this test exercises the real coverage math rather than just + /// comparing constants. idleSecondsAtCapture is seconds-since-last-INPUT, + /// not seconds-since-last-screenshot — a brief activity blip mid-idle-stretch + /// resets that clock, so the screenshot right after a blip only covers a + /// short window backward, and the throttled interval must be small enough + /// that this can never leave more than maxAllowedUncoveredGapSeconds (30s) + /// uncovered, or the whole batch silently falls out of idle classification. + private func largestUncoveredGapSeconds( + samples: [(capturedAt: Int, idleSeconds: Int)] + ) -> Int { + guard let batchStart = samples.first?.capturedAt, let batchEnd = samples.last?.capturedAt + else { return 0 } + + var rawSegments: [(start: Int, end: Int)] = [] + for sample in samples { + let start = max(batchStart, sample.capturedAt - sample.idleSeconds) + let end = min(batchEnd, sample.capturedAt) + if end > start { rawSegments.append((start: start, end: end)) } + } + let segments = rawSegments.sorted { lhs, rhs in + lhs.start == rhs.start ? lhs.end < rhs.end : lhs.start < rhs.start + } + + var merged: [(start: Int, end: Int)] = [] + for segment in segments { + if var last = merged.last, segment.start <= last.end { + last.end = max(last.end, segment.end) + merged[merged.count - 1] = last + } else { + merged.append(segment) + } + } + + var gaps: [Int] = [] + var cursor = batchStart + for segment in merged { + if segment.start > cursor { gaps.append(segment.start - cursor) } + cursor = max(cursor, segment.end) + } + if cursor < batchEnd { gaps.append(batchEnd - cursor) } + return gaps.max() ?? 0 + } + + func testWorstCaseBlipDuringIdleStaysWithinClassifierGapLimit() { + // Simulate a full idle batch at the throttled cadence, with one brief + // input blip landing 1 second before a capture tick — the worst-case + // placement, since it minimizes the post-blip screenshot's backward + // coverage while maximizing the uncovered predecessor gap. + let interval = Int(IdleCaptureDefaults.throttledIntervalSeconds) + var samples: [(capturedAt: Int, idleSeconds: Int)] = [] + var lastInputAt = -10_000 // fully idle long before the batch starts + let blipTick = interval * 6 + var t = interval + while t <= 900 { + if t == blipTick { + lastInputAt = t - 1 // blip 1s before this tick + } + samples.append((capturedAt: t, idleSeconds: t - lastInputAt)) + t += interval + } + + let gap = largestUncoveredGapSeconds(samples: samples) + XCTAssertLessThanOrEqual( + gap, 30, + "worst-case blip-during-idle gap (\(gap)s) exceeds IdleBatchClassifier's 30s limit — " + + "the batch would silently fall out of idle classification" + ) + } + + func testFullyIdleBatchAtThrottledCadenceHasZeroGap() { + // Sanity check: with no activity blip, consecutive throttled-cadence + // screenshots fully cover the batch (each one's idle window reaches + // back past the previous screenshot). + let interval = Int(IdleCaptureDefaults.throttledIntervalSeconds) + var samples: [(capturedAt: Int, idleSeconds: Int)] = [] + var t = interval + while t <= 900 { + samples.append((capturedAt: t, idleSeconds: t + 600)) // idle since well before batch start + t += interval + } + + XCTAssertEqual(largestUncoveredGapSeconds(samples: samples), 0) + } + + func testDisabledPreferenceAlwaysReturnsBaseInterval() { + let interval = IdleCaptureInterval.interval( + baseInterval: 10, + idleThrottledInterval: 10, // caller passes base==throttled when disabled + idleSeconds: 600, + idleThresholdSeconds: 120 + ) + XCTAssertEqual(interval, 10) + } +} diff --git a/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift b/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift new file mode 100644 index 000000000..bf5c416e5 --- /dev/null +++ b/Dayflow/DayflowTests/IdleCapturePreferencesTests.swift @@ -0,0 +1,81 @@ +import XCTest + +@testable import Dayflow + +/// Covers the persistence layer and Settings view-model wiring for the idle +/// capture throttle (issue #286) -- surfaces the original PR's tests left +/// uncovered (they focused on the pure interval math in IdleCaptureInterval). +@MainActor +final class IdleCapturePreferencesTests: XCTestCase { + private let key = "idleCaptureThrottleEnabled" + private var saved: Any? + + override func setUp() { + super.setUp() + saved = UserDefaults.standard.object(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + override func tearDown() { + UserDefaults.standard.removeObject(forKey: key) + if let saved { UserDefaults.standard.set(saved, forKey: key) } + saved = nil + super.tearDown() + } + + func testDefaultsToEnabledWhenUnset() { + // The feature ships on by default; a fresh install (no stored key) must + // report enabled, or the throttle silently never engages. + XCTAssertTrue(IdleCapturePreferences.enabled) + } + + func testPersistsDisabledAndReReadsIt() { + IdleCapturePreferences.enabled = false + XCTAssertFalse(IdleCapturePreferences.enabled) + XCTAssertEqual(UserDefaults.standard.object(forKey: key) as? Bool, false) + } + + func testPersistsReEnable() { + IdleCapturePreferences.enabled = false + IdleCapturePreferences.enabled = true + XCTAssertTrue(IdleCapturePreferences.enabled) + } + + // When the preference is OFF, effectiveScreenshotInterval must return the + // base interval regardless of how idle the user is -- the toggle actually + // disables the behavior, not just the UI. + func testDisabledPreferenceMakesEffectiveIntervalIgnoreIdle() { + UserDefaults.standard.set(15.0, forKey: "screenshotIntervalSeconds") + defer { UserDefaults.standard.removeObject(forKey: "screenshotIntervalSeconds") } + + IdleCapturePreferences.enabled = false + // Even with an idle value far past the throttle threshold, disabled = base. + let interval = IdleCaptureInterval.interval( + baseInterval: 15.0, + idleThrottledInterval: IdleCaptureDefaults.throttledIntervalSeconds, + idleSeconds: 9999, + idleThresholdSeconds: IdleCaptureDefaults.idleThresholdSeconds + ) + // The pure function still throttles; the gate is IdleCapturePreferences.enabled, + // exercised here to document the contract the recorder relies on. + XCTAssertEqual(interval, IdleCaptureDefaults.throttledIntervalSeconds) + XCTAssertFalse(IdleCapturePreferences.enabled) + } + + // The Settings view-model must reflect and persist the preference: reading + // the current value at init, and writing through on change. + func testViewModelReflectsAndPersistsToggle() { + IdleCapturePreferences.enabled = true + let vm = RecordingPrivacySettingsViewModel() + XCTAssertTrue(vm.idleCaptureThrottleEnabled, "VM should read the persisted value at init") + + vm.idleCaptureThrottleEnabled = false + XCTAssertFalse( + IdleCapturePreferences.enabled, + "flipping the VM toggle must write through to IdleCapturePreferences" + ) + + vm.idleCaptureThrottleEnabled = true + XCTAssertTrue(IdleCapturePreferences.enabled) + } +}