# File Storage Patterns

Downloaded and uploaded data has to live somewhere. Where it lives decides
whether it is backed up, whether the system may delete it, and when it can be
read.

## Directory Selection Guide

Ask three questions: did the user create it, can the app recreate it, and is
it only needed for the next few minutes?

| Directory | Backed up | System may purge | Put here |
|---|---|---|---|
| `Documents/` | Yes | No | Things the user made or explicitly saved |
| `Library/Application Support/` | Yes | No | Data the app itself owns: databases, configuration, caches that must survive an update |
| `Library/Caches/` | No | Yes, when storage runs low | Anything that can be downloaded or rebuilt |
| `tmp/` | No | Yes, at any time | Scratch files, in-flight upload bodies |

Ask `FileManager` for the location; never build these paths by hand.

```swift
let fm = FileManager.default
let documents = try fm.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let support = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let caches = try fm.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: false)
let scratch = fm.temporaryDirectory

let exportedInvoice = documents.appending(path: "invoice-2031.pdf")
let catalogDatabase = support.appending(path: "catalog.sqlite")
let coverThumbnail = caches.appending(path: "covers/1182.jpg")
let pendingUpload = scratch.appending(path: "upload-\(UUID().uuidString).bin")
```

`FileManager.default.urls(for:in:)` returns the same locations as an array.
Application Support does not exist on a fresh install. Pass `create: true`
as above, or call `createDirectory(at:withIntermediateDirectories: true)`
before writing into it.

## FileProtectionType Levels

Data Protection encrypts files at rest, and the protection level decides
whether a file can be read while the device is locked. See the
[protection type reference](https://developer.apple.com/documentation/foundation/fileprotectiontype)
and the [file encryption article](https://developer.apple.com/documentation/uikit/encrypting-your-app-s-files).

| Level | Readable | Good for |
|---|---|---|
| `.complete` | Only while unlocked | Health, finance and other sensitive records |
| `.completeUnlessOpen` | A file opened before locking stays usable | Recordings or downloads in progress at lock time |
| `.completeUntilFirstUserAuthentication` | Any time after the first unlock since boot (the default) | Most app data, including data used in the background |
| `.none` | Always, even before the first unlock | Non-sensitive data needed at boot |

```swift
try statement.write(to: fileURL, options: .completeFileProtection)

let files = FileManager.default
let lockedDown: [FileAttributeKey: Any] = [.protectionKey: FileProtectionType.complete]
try files.setAttributes(lockedDown, ofItemAtPath: vaultFolder.path())

let attributes = try files.attributesOfItem(atPath: fileURL.path())
let level = attributes[.protectionKey] as? FileProtectionType
```

Protection set on a directory applies to files created in it afterwards.

A `.complete` file cannot be opened while the device is locked. Check
`UIApplication.shared.isProtectedDataAvailable` before touching one, and
watch the transitions:

```swift
let center = NotificationCenter.default
let lockSoon = center.notifications(named: UIApplication.protectedDataWillBecomeUnavailableNotification)
let unlocked = center.notifications(named: UIApplication.protectedDataDidBecomeAvailableNotification)
```

Close handles to `.complete` files when the will-become-unavailable
notification arrives; reopen them after the did-become-available one.

## Backup Exclusion (isExcludedFromBackup)

Large content that can be fetched again should not go into iCloud or
computer backups. App Review can reject an app that backs up a lot of
re-creatable data. See the
[resource values reference](https://developer.apple.com/documentation/foundation/urlresourcevalues).

```swift
func keepOutOfBackup(_ item: URL) throws {
    var target = item
    var values = URLResourceValues()
    values.isExcludedFromBackup = true
    try target.setResourceValues(values)
}

func skipsBackup(_ item: URL) throws -> Bool {
    let values = try item.resourceValues(forKeys: [.isExcludedFromBackupKey])
    return values.isExcludedFromBackup ?? false
}
```

| Exclude | Keep in backup |
|---|---|
| Downloaded media | The user's documents |
| API response caches | Preferences and settings |
| Generated thumbnails and previews | Databases holding user data |
| Map tiles cached for offline use | In-app purchase receipts |
| Precomputed search indexes | Anything the user created |

A useful middle ground: put re-downloadable data in a subfolder of
Application Support and exclude that folder from backup. It survives app
updates and system purges without inflating the backup.

## Storage Pressure Handling

Once free space gets tight, iOS may empty `Library/Caches/` and
`tmp/` without asking. Do not rely on that alone; keep your own footprint in
check.

### Measuring free space

```swift
func freeBytes(forImportantWork important: Bool) throws -> Int64 {
    let key: URLResourceKey = important ? .volumeAvailableCapacityForImportantUsageKey
                                        : .volumeAvailableCapacityForOpportunisticUsageKey
    let values = try URL.homeDirectory.resourceValues(forKeys: [key])
    return (important ? values.volumeAvailableCapacityForImportantUsage
                      : values.volumeAvailableCapacityForOpportunisticUsage) ?? 0
}

func hasRoom(for byteCount: Int64) -> Bool {
    ((try? freeBytes(forImportantWork: true)) ?? 0) >= byteCount
}
```

Use the important-usage figure for work the user asked for and the
opportunistic figure for optional work such as prefetching. The
opportunistic value is never larger than the important one.

### Where low-disk signals actually come from

There is no general system notification that says storage is low. The
signals an app can use are:

- The capacity keys above, read at launch and before any large download,
  export or cache fill. Below a threshold you choose (for example 100 MB),
  run cleanup and skip optional work.
- Write failures. A write that runs out of space throws
  `CocoaError(.fileWriteOutOfSpace)` (POSIX `ENOSPC` underneath). Catch it,
  free cache space, and retry once or tell the user.
- `NSBundleResourceRequestLowDiskSpace`, posted only to apps that use
  On-Demand Resources, when the system needs space back from them.

`UIApplication.didReceiveMemoryWarningNotification` is about RAM, not disk.
Respond to it by dropping in-memory caches (an `NSCache` does this on its
own); it says nothing about free storage and is not a reason to delete files.

```swift
func writeCache(_ blob: Data, into file: URL, cleaner: CacheJanitor) throws {
    do {
        try blob.write(to: file, options: .atomic)
    } catch let failure as CocoaError where failure.code == .fileWriteOutOfSpace {
        try cleaner.sweep()
        try blob.write(to: file, options: .atomic)
    }
}
```

### Cleaning a cache directory

```swift
struct CacheJanitor {
    let folder: URL
    var maxAge: Duration = .seconds(7 * 24 * 60 * 60)
    var maxBytes = 500 * 1024 * 1024

    private struct Entry { let url: URL; let modified: Date; let size: Int }

    func sweep() throws {
        try removeExpired()
        try trimToBudget()
    }

    func removeExpired() throws {
        let cutoff = Date.now.addingTimeInterval(-Double(maxAge.components.seconds))
        for entry in try entries() where entry.modified < cutoff {
            try FileManager.default.removeItem(at: entry.url)
        }
    }

    func trimToBudget() throws {
        let oldestFirst = try entries().sorted { $0.modified < $1.modified }
        var total = oldestFirst.reduce(0) { $0 + $1.size }
        for entry in oldestFirst where total > maxBytes {
            try FileManager.default.removeItem(at: entry.url)
            total -= entry.size
        }
    }

    private func entries() throws -> [Entry] {
        let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
        return try FileManager.default
            .contentsOfDirectory(at: folder, includingPropertiesForKeys: keys, options: .skipsHiddenFiles)
            .compactMap { item in
                let info = try item.resourceValues(forKeys: Set(keys))
                guard let modified = info.contentModificationDate else { return nil }
                return Entry(url: item, modified: modified, size: info.fileSize ?? 0)
            }
    }
}
```

`sweep()` deletes files past their age first, then removes the least
recently modified files until the folder fits its size budget.
