There may be thousands of files so doing a full scan every time the app was brought into the foreground would be painfull.
Hmmm, there are two separate concepts present in that sentence:
On the foreground vs background issue, be aware that your Documents directory can be changed while your app is in the foreground (for example, iTunes file sharing via
UIFileSharingEnabled
), so doing a full scan when coming to the foreground is not sufficient to stay up to date.
On the notifications vs full scans issue, the standard notification for this is a kqueue, best accessed via a dispatch source. This only gives you a notification that something has changed in the directory. It does not tell you what’s changed. You will have to read the entire directory and compare it to your internal state.
Having said that, if you’re only expecting thousands of files that shouldn’t be too onerous even on the slowest modern (iOS 9 and later) iOS device. You’ll probably want to do the scan on a secondary thread though.
You can find official Apple sample code for the kqueue approach in the DocInteraction sample code. I’m not aware of any official Apple sample code for the dispatch source approach but I’ve pasted in some (Swift 3) code below that shows how it works.
Be aware that this stuff won’t resume your app in background when there are changes. Rather, if your app is suspended it’ll stay suspended, and when it’s next resumed you’ll get the notification.
Share and Enjoy
—
Quinn “The Eskimo!”
Apple Developer Relations, Developer Technical Support, Core OS/Hardware
let myEmail = "eskimo" + "1" + "@apple.com"
class DirectoryMonitor {
typealias Delegate = DirectoryMonitorDelegate
init(directory: URL, matching typeIdentifier: String, requestedResourceKeys: Set<URLResourceKey>) {
self.directory = directory
self.typeIdentifier = typeIdentifier
self.requestedResourceKeys = requestedResourceKeys
self.actualResourceKeys = [URLResourceKey](requestedResourceKeys.union([.typeIdentifierKey]))
self.contents = []
}
let typeIdentifier: String
let requestedResourceKeys: Set<URLResourceKey>
private let actualResourceKeys: [URLResourceKey]
let directory: URL
weak var delegate: Delegate? = nil
private(set) var contents: Set<URL>
fileprivate enum State {
case stopped
case started(dirSource: DispatchSourceFileSystemObject)
case debounce(dirSource: DispatchSourceFileSystemObject, timer: Timer)
}
private var state: State = .stopped
private static func source(for directory: URL) throws -> DispatchSourceFileSystemObject {
let dirFD = open(directory.path, O_EVTONLY)
guard dirFD >= 0 else {
let err = errno
throw NSError(domain: POSIXError.errorDomain, code: Int(err), userInfo: nil)
}
return DispatchSource.makeFileSystemObjectSource(
fileDescriptor: dirFD,
eventMask: [.write],
queue: DispatchQueue.main
)
}
func start() throws {
guard case .stopped = self.state else { fatalError() }
let dirSource = try DirectoryMonitor.source(for: self.directory)
dirSource.setEventHandler {
self.kqueueDidFire()
}
dirSource.resume()
// We don't support `stop()` so there's no cancellation handler.
// kqueue.source.setCancelHandler {
// _ = close(...)
// }
let nowTimer = Timer.scheduledTimer(withTimeInterval: 0.0, repeats: false) { _ in
self.debounceTimerDidFire()
}
self.state = .debounce(dirSource: dirSource, timer: nowTimer)
}
private func kqueueDidFire() {
switch self.state {
case .started(let dirSource):
let timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: false) { _ in
self.debounceTimerDidFire()
}
self.state = .debounce(dirSource: dirSource, timer: timer)
case .debounce(_, let timer):
timer.fireDate = Date(timeIntervalSinceNow: 0.2)
// Stay in the `.debounce` state.
case .stopped:
// This can happen if the read source fired and enqueued a block on the
// main queue but, before the main queue got to service that block, someone
// called `stop()`. The correct response is to just do nothing.
break
}
}
static func contents(of directory: URL, matching typeIdentifier: String, including: [URLResourceKey]) -> Set<URL> {
guard let rawContents = try? FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: including,
options: [.skipsHiddenFiles]
) else {
return []
}
let filteredContents = rawContents.filter { url in
guard let v = try? url.resourceValues(forKeys: [.typeIdentifierKey]),
let urlType = v.typeIdentifier else {
return false
}
return urlType == typeIdentifier
}
return Set(filteredContents)
}
private func debounceTimerDidFire() {
guard case .debounce(let dirSource, let timer) = self.state else { fatalError() }
timer.invalidate()
self.state = .started(dirSource: dirSource)
let newContents = DirectoryMonitor.contents(of: self.directory, matching: self.typeIdentifier, including: self.actualResourceKeys)
let itemsAdded = newContents.subtracting(self.contents)
let itemsRemoved = self.contents.subtracting(newContents)
self.contents = newContents
if !itemsAdded.isEmpty || !itemsRemoved.isEmpty {
self.delegate?.didChange(directoryMonitor: self, added: itemsAdded, removed: itemsRemoved)
}
}
func stop() {
if !self.state.isRunning { fatalError() }
// I don't need an implementation for this in the current project so
// I'm just leaving it out for the moment.
fatalError()
}
}
fileprivate extension DirectoryMonitor.State {
var isRunning: Bool {
switch self {
case .stopped: return false
case .started: return true
case .debounce: return true
}
}
}
protocol DirectoryMonitorDelegate : AnyObject {
func didChange(directoryMonitor: DirectoryMonitor, added: Set<URL>, removed: Set<URL>)
}