Skip to content
C.W.K.
Stream
Lesson 05 of 06 · published

Data Protection: Choosing When Your Own App Can Read Its Files

~15 min · universal-ios, data-protection, security, files, background

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"The app cannot read its own files while the phone is locked, so any background drain has to check first."

Encryption You Choose Per File

Every file an iOS app writes is encrypted, and its protection class decides when the key to read it exists. By default a new file gets completeUntilFirstUserAuthentication: sealed from boot until the first unlock, then readable whenever the phone is on, locked or not. That is the right default for most data. An app holding health records, prescriptions and check-up PDFs, or a writer's private notes, can choose complete instead: the file is readable only while the device is unlocked. The training app chose it for its records and its share inbox, and the prose editor for its device library.

The Cost Has to Be Designed, Not Discovered

complete applies to the app itself. While the phone is locked, your own code cannot open those files, so anything that runs in the background (a sync, an outbox drain, a transfer arriving from the watch) will meet I/O errors if it assumes they are readable. The design has three parts. A background drain checks UIApplication.shared.isProtectedDataAvailable before every file it reads, because the phone can lock while a send is still waiting, and reports "waiting for unlock" as a state, not an error. The app listens for protectedDataDidBecomeAvailableNotification and resumes then. And a file that must be created while the phone may be locked, such as a capture delivered in the background, uses completeUnlessOpen, which allows creation while locked and seals the file once it is closed.

Proving It Needs a Device

The Simulator does not implement Data Protection. A write with .completeFileProtection succeeds, no class is recorded, and attributesOfItem returns no protection key, with no error anywhere. The training app's suite first read that as a broken write path. The family's answer, from the proof track, is to split the check: the policy constants are asserted everywhere, and the class actually recorded on disk is asserted on a device, behind a skip that says why. A related measurement from reading a phone over the cable: a device container copy silently leaves out files it cannot open, so a complete-protected inbox can be missing from the copy while it exists on the phone.

Shared Containers Follow the Same Rules

An App Group container shared by the app and its extensions is just another place files live, and each writer sets the class on its own writes. Decide the class once per kind of file, write it down beside the store, and use the same option in the app and in every extension that writes there.

Code

The four classes, and how to pick one·text
class                                    readable while locked?          created while locked?
---------------------------------------  ------------------------------  ---------------------
.none                                    yes                             yes
.completeUntilFirstUserAuthentication    yes, after the first unlock     yes   (the default)
.completeUnlessOpen                      only if it was already open     yes
.complete                                no                              no

Choose by what the file holds and when the app must touch it:
  health records, a writer's notes, a share inbox  -> .complete, and drain only when unlocked
  a capture that arrives in the background         -> .completeUnlessOpen, so it can be written
  caches that are cheap to rebuild                 -> the default
A complete-protected outbox that waits for unlock instead of failing·swift
import Foundation
import UIKit

enum DrainStatus: Equatable, Sendable {
    case drained(Int)
    case waitingForUnlock          // not an error: the files are there, just sealed
}

@MainActor
final class ProtectedOutbox {
    private let folder: URL

    init(folder: URL) {
        self.folder = folder
    }

    /// Complete: unreadable while the device is locked, even by this app.
    func save(_ data: Data, named name: String) throws {
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
        try data.write(to: folder.appending(path: name), options: [.atomic, .completeFileProtection])
    }

    /// A background drain checks before every read, and says what it is waiting for instead of
    /// throwing I/O errors. The phone can lock while a send is suspended at the await.
    func drain(send: (Data) async throws -> Void) async throws -> DrainStatus {
        guard UIApplication.shared.isProtectedDataAvailable else { return .waitingForUnlock }
        let files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
        var sent = 0
        for file in files.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
            guard UIApplication.shared.isProtectedDataAvailable else { return .waitingForUnlock }
            let data: Data
            do {
                data = try Data(contentsOf: file)
            } catch where !UIApplication.shared.isProtectedDataAvailable {
                return .waitingForUnlock            // locked between the check and the read
            }
            try await send(data)
            try FileManager.default.removeItem(at: file)
            sent += 1
        }
        return .drained(sent)
    }

    /// Resume the moment the device unlocks.
    func onUnlock(_ resume: @escaping @MainActor () -> Void) -> NSObjectProtocol {
        NotificationCenter.default.addObserver(
            forName: UIApplication.protectedDataDidBecomeAvailableNotification,
            object: nil, queue: .main
        ) { _ in
            MainActor.assumeIsolated { resume() }
        }
    }
}

External links

Exercise

Add ProtectedOutbox to SparkMobile and save one entry. On a real iPhone, start a drain from a background task, lock the phone before it runs, and record the status it returns; unlock and confirm the observer resumes it. Then add a device-only test that writes a file with .completeFileProtection and asserts the class recorded by attributesOfItem, skipped with a reason on the Simulator.
Hint
isProtectedDataAvailable does not turn false the instant the screen locks, so give it a little time before starting the drain. Read the class with try FileManager.default.attributesOfItem(atPath:)[.protectionKey] as? FileProtectionType.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.