diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 65800548..4bbcc915 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -141,11 +141,13 @@ struct ContentView: View { let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() init(keypair: Keypair, appDelegate: AppDelegate?) { - // Compact the database if requested from the previous session. - // This runs before opening the main Ndb instance so that it works on an idle database. + // Schedule automatic compaction based on the user's configured interval, then + // run compaction if it was previously requested (either manually or by the scheduler). + // Both calls run before opening the main Ndb instance so they work on an idle database. // This also gets run here instead of `connect` because we should anticipate this to add a few seconds of delay in worst case scenarios. // If we were to add this in the `connect` function, parallel functions that depend on `damus_state!` could cause crashes in the app. // By placing this here, we only delay the splash screen a bit + Ndb.schedule_auto_compact_if_needed() Ndb.compact_if_needed() self.keypair = keypair diff --git a/damus/Features/Settings/Views/StorageSettingsView.swift b/damus/Features/Settings/Views/StorageSettingsView.swift index 0d1ec618..13578989 100644 --- a/damus/Features/Settings/Views/StorageSettingsView.swift +++ b/damus/Features/Settings/Views/StorageSettingsView.swift @@ -54,6 +54,7 @@ struct StorageSettingsView: View { @State var showing_cache_clear_alert: Bool = false @State fileprivate var compact_scheduling_state: CompactSchedulingState = .not_scheduled @State var showing_compact_alert: Bool = false + @State fileprivate var auto_compact_schedule: AutoCompactSchedule = Ndb.get_auto_compact_schedule() /// Storage categories with cumulative ranges for angle selection (iOS 17+) private var categoryRanges: [(category: String, range: Range)] { @@ -174,6 +175,9 @@ struct StorageSettingsView: View { self.ClearCacheButton self.CompactDatabaseButton } + + // Auto-compact Section + self.AutoCompactSection } // Loading state @@ -352,6 +356,77 @@ struct StorageSettingsView: View { } } + /// Section that lets the user configure automatic periodic compaction. + /// + /// Shows a `Picker` with four schedule options and a caption that describes the + /// current state: either the time remaining until the next compaction or a note + /// that compaction will run on the next app launch. + var AutoCompactSection: some View { + Section( + header: Text("Auto-Compact", comment: "Section header for automatic database compaction schedule"), + footer: AutoCompactCaption + ) { + Picker( + NSLocalizedString("Automatically compact database", comment: "Setting label for choosing how often to auto-compact the database"), + selection: $auto_compact_schedule + ) { + ForEach(AutoCompactSchedule.allCases, id: \.self) { option in + Text(option.text_description()).tag(option) + } + } + .onChange(of: auto_compact_schedule) { newSchedule in + Ndb.set_auto_compact_schedule(newSchedule) + } + } + } + + /// Caption displayed below the auto-compact picker. + /// + /// Shows either the time remaining until the next scheduled compaction or a message + /// indicating that compaction will happen on the next app launch. + private var AutoCompactCaption: some View { + Group { + if auto_compact_schedule == .never { + Text("Automatic database compaction is disabled.", comment: "Caption shown when auto-compact is set to never") + } else if compact_scheduling_state == .scheduled { + Text("Will compact on the next app launch.", comment: "Caption shown when a compaction is already queued for the next launch") + } else if let timeRemaining = nextCompactTimeRemaining { + Text( + String( + format: NSLocalizedString( + "Next automatic compaction %@.", + comment: "Caption showing how long until the next automatic compaction. %@ is replaced with a human-readable duration like 'in 3 days'." + ), + timeRemaining + ) + ) + } else { + Text("Will compact on the next app launch.", comment: "Caption shown when the scheduled compaction interval has already elapsed") + } + } + .font(.caption) + .foregroundColor(.secondary) + } + + /// Human-readable string describing the time remaining until the next auto-compaction, + /// or `nil` if the interval has already elapsed (i.e., compaction is due now). + private var nextCompactTimeRemaining: String? { + guard let interval = auto_compact_schedule.interval else { return nil } + let lastDate = Ndb.get_last_compact_date() ?? .distantPast + let nextDate = lastDate.addingTimeInterval(interval) + let remaining = nextDate.timeIntervalSince(Date()) + guard remaining > 0 else { return nil } + + return Self.relativeFormatter.localizedString(fromTimeInterval: remaining) + } + + /// Shared formatter for human-readable relative durations (e.g. "in 3 days"). + private static let relativeFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() + /// Compact database button view with confirmation dialog. /// /// Schedules a one-time database compaction to run on the next app launch. The user diff --git a/damusTests/NdbCompactionTests.swift b/damusTests/NdbCompactionTests.swift index c49076c8..a92f43e0 100644 --- a/damusTests/NdbCompactionTests.swift +++ b/damusTests/NdbCompactionTests.swift @@ -17,6 +17,9 @@ final class NdbCompactionTests: XCTestCase { try FileManager.default.createDirectory(at: testDirectory, withIntermediateDirectories: true) // Ensure the flag is cleared before each test. UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key) + // Reset scheduling-related keys so tests start from a clean state. + UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key) + UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key) } override func tearDown() async throws { @@ -25,6 +28,9 @@ final class NdbCompactionTests: XCTestCase { } // Leave the flag cleared after each test. UserDefaults.standard.set(false, forKey: Ndb.compact_on_next_launch_key) + // Clean up scheduling keys. + UserDefaults.standard.removeObject(forKey: Ndb.auto_compact_schedule_key) + UserDefaults.standard.removeObject(forKey: Ndb.last_compact_date_key) try await super.tearDown() } @@ -171,4 +177,117 @@ final class NdbCompactionTests: XCTestCase { "lock.mdb should be recreated by LMDB after opening the compacted database" ) } + + // MARK: - AutoCompactSchedule: interval values + + func testAutoCompactSchedule_intervalValues() { + XCTAssertEqual(AutoCompactSchedule.daily.interval, 60 * 60 * 24, "daily interval should be 24 hours") + XCTAssertEqual(AutoCompactSchedule.weekly.interval, 60 * 60 * 24 * 7, "weekly interval should be 7 days") + XCTAssertEqual(AutoCompactSchedule.monthly.interval, 60 * 60 * 24 * 30, "monthly interval should be 30 days") + XCTAssertNil(AutoCompactSchedule.never.interval, ".never should have no interval") + } + + // MARK: - get/set auto-compact schedule + + func testGetAutoCompactSchedule_returnsWeeklyByDefault() { + // No key stored → default should be .weekly + XCTAssertEqual(Ndb.get_auto_compact_schedule(), .weekly) + } + + func testSetAndGetAutoCompactSchedule_roundTrips() { + for schedule in AutoCompactSchedule.allCases { + Ndb.set_auto_compact_schedule(schedule) + XCTAssertEqual(Ndb.get_auto_compact_schedule(), schedule, + "Round-trip failed for schedule: \(schedule)") + } + } + + // MARK: - schedule_auto_compact_if_needed + + func testScheduleAutoCompact_doesNothingWhenNever() { + Ndb.set_auto_compact_schedule(.never) + + Ndb.schedule_auto_compact_if_needed() + + XCTAssertFalse( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "schedule=never should never set the compact flag" + ) + } + + func testScheduleAutoCompact_setsFlag_whenIntervalHasElapsed() { + // Given: schedule is weekly, last compaction was 8 days ago + Ndb.set_auto_compact_schedule(.weekly) + let eightDaysAgo = Date().addingTimeInterval(-(60 * 60 * 24 * 8)) + UserDefaults.standard.set(eightDaysAgo, forKey: Ndb.last_compact_date_key) + + // When + Ndb.schedule_auto_compact_if_needed() + + // Then: flag should be set + XCTAssertTrue( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "compact flag should be set when scheduled interval has elapsed" + ) + } + + func testScheduleAutoCompact_doesNotSetFlag_whenIntervalHasNotElapsed() { + // Given: schedule is weekly, last compaction was 1 day ago + Ndb.set_auto_compact_schedule(.weekly) + let oneDayAgo = Date().addingTimeInterval(-(60 * 60 * 24)) + UserDefaults.standard.set(oneDayAgo, forKey: Ndb.last_compact_date_key) + + // When + Ndb.schedule_auto_compact_if_needed() + + // Then: flag should NOT be set + XCTAssertFalse( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "compact flag should not be set when the scheduled interval has not yet elapsed" + ) + } + + func testScheduleAutoCompact_setsFlag_whenNoLastCompactDate() { + // Given: schedule is daily, no last compact date stored (first launch) + Ndb.set_auto_compact_schedule(.daily) + // last_compact_date_key is absent (cleaned in setUp) + + // When + Ndb.schedule_auto_compact_if_needed() + + // Then: flag should be set (distantPast triggers compaction on first launch) + XCTAssertTrue( + UserDefaults.standard.bool(forKey: Ndb.compact_on_next_launch_key), + "compact flag should be set on first launch (no previous compaction date)" + ) + } + + // MARK: - compact_if_needed: records last compact date + + func testCompactIfNeeded_recordsLastCompactDate_afterSuccessfulCompaction() { + let dbPath = testDirectory.appendingPathComponent("date_record_db").path + try? FileManager.default.createDirectory(atPath: dbPath, withIntermediateDirectories: true) + + guard let ndb = Ndb(path: dbPath) else { + XCTFail("Could not open Ndb at \(dbPath)") + return + } + ndb.close() + + XCTAssertNil(Ndb.get_last_compact_date(), "No last compact date should exist before first compaction") + + Ndb.set_compact_on_next_launch() + + let beforeCompact = Date() + Ndb.compact_if_needed(db_path: dbPath) + let afterCompact = Date() + + guard let lastDate = Ndb.get_last_compact_date() else { + XCTFail("Last compact date should be set after successful compaction") + return + } + + XCTAssertGreaterThanOrEqual(lastDate, beforeCompact, "last compact date should be >= time before compaction started") + XCTAssertLessThanOrEqual(lastDate, afterCompact, "last compact date should be <= time after compaction ended") + } } diff --git a/nostrdb/Ndb+Compaction.swift b/nostrdb/Ndb+Compaction.swift index e1f9b37d..983285d7 100644 --- a/nostrdb/Ndb+Compaction.swift +++ b/nostrdb/Ndb+Compaction.swift @@ -5,6 +5,38 @@ import Foundation +/// Defines how often the database should be automatically compacted. +enum AutoCompactSchedule: String, CaseIterable, Equatable { + case daily + case weekly + case monthly + case never + + /// Human-readable label shown in the settings UI. + func text_description() -> String { + switch self { + case .daily: + return NSLocalizedString("Once a day", comment: "Auto-compact schedule option: compact once a day") + case .weekly: + return NSLocalizedString("Once a week", comment: "Auto-compact schedule option: compact once a week") + case .monthly: + return NSLocalizedString("Once a month", comment: "Auto-compact schedule option: compact once a month") + case .never: + return NSLocalizedString("Never", comment: "Auto-compact schedule option: never auto-compact") + } + } + + /// The time interval (in seconds) between automatic compactions, or `nil` for `.never`. + var interval: TimeInterval? { + switch self { + case .daily: return 60 * 60 * 24 + case .weekly: return 60 * 60 * 24 * 7 + case .monthly: return 60 * 60 * 24 * 30 + case .never: return nil + } + } +} + extension Ndb { /// Makes a compacted copy of the database in a separate directory. /// @@ -37,6 +69,12 @@ extension Ndb { /// The `UserDefaults` key used to signal that the database should be compacted on the next app launch. static let compact_on_next_launch_key = "ndb_compact_on_next_launch" + /// The `UserDefaults` key used to persist the auto-compact schedule (stored as raw string). + static let auto_compact_schedule_key = "ndb_auto_compact_schedule" + + /// The `UserDefaults` key used to record when the last successful compaction occurred. + static let last_compact_date_key = "ndb_last_compact_date" + /// Requests that the database be compacted the next time the app launches. /// /// Call this to schedule a one-time compaction. The flag is cleared automatically after @@ -45,6 +83,43 @@ extension Ndb { UserDefaults.standard.set(true, forKey: compact_on_next_launch_key) } + /// Reads the persisted auto-compact schedule from `UserDefaults`. + /// + /// Defaults to `.weekly` if no value has been saved yet. + static func get_auto_compact_schedule() -> AutoCompactSchedule { + guard let raw = UserDefaults.standard.string(forKey: auto_compact_schedule_key), + let schedule = AutoCompactSchedule(rawValue: raw) else { + return .weekly + } + return schedule + } + + /// Persists the auto-compact schedule to `UserDefaults`. + static func set_auto_compact_schedule(_ schedule: AutoCompactSchedule) { + UserDefaults.standard.set(schedule.rawValue, forKey: auto_compact_schedule_key) + } + + /// Returns the date of the last successful compaction, or `nil` if none has occurred. + static func get_last_compact_date() -> Date? { + return UserDefaults.standard.object(forKey: last_compact_date_key) as? Date + } + + /// Sets the compact-on-next-launch flag if the scheduled interval has elapsed since the + /// last successful compaction. + /// + /// Call this once on app startup **before** `compact_if_needed()`. + static func schedule_auto_compact_if_needed() { + let schedule = get_auto_compact_schedule() + guard let interval = schedule.interval else { return } + + let now = Date() + let lastDate = get_last_compact_date() ?? .distantPast + guard now.timeIntervalSince(lastDate) >= interval else { return } + + Log.info("Auto-compact: interval elapsed — scheduling compaction on next launch", for: .storage) + set_compact_on_next_launch() + } + /// Compacts the NostrDB database files if the compact-on-next-launch flag is set. /// /// This is intended to be called once during app startup **before** `Ndb` is opened for @@ -154,6 +229,9 @@ extension Ndb { // Clean up the temp directory (any remaining files such as lock.mdb). try? FileManager.default.removeItem(atPath: tempPath) + // Record the date of this successful compaction for the auto-compact scheduler. + UserDefaults.standard.set(Date(), forKey: last_compact_date_key) + // Clear the flag so we don't compact again on the next launch. UserDefaults.standard.set(false, forKey: compact_on_next_launch_key) }