Never Silent While Waiting, and a Still Keeps Its Truth
~14 min · background-tasks, notifications, exif, capture-truth
Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
A capture that went quiet
Here is the failure, without the trip it happened on. A capture lost its send with the link right after the shutter. The phone sent only while Spark was open, and Dad did not open it again for days. When a later capture finally drained the queue, the old photo reached Pippa beside fresh ones and read as if it had just been taken. Nothing on the icon or the lock screen had said a capture was waiting.
Three things lined up to make that possible. The outbox drained only while Spark was in the foreground. The 60-second sync loop's drain was cancelled together with the scene when Dad left the app. And iOS suspends a backgrounded app a few seconds after it leaves, mid-request if a request is running. The outbox had kept the photo safe, exactly as the first lesson promised. It had also kept it secret.
Three answers on the phone
A send under way finishes. Every drain holds a background task assertion, the time iOS grants a started piece of work after the user leaves. The drain ends it when the pass is done. If iOS's time runs out first, the entry in flight is recovered to waiting on the next pass, as after any interruption. The sync loop's drain also runs in a task of its own, so leaving the app no longer cancels it.
iOS is asked to wake Spark. While anything can still be sent, Spark submits a background app refresh request, com.cwk.spark.mobile.outbox, with an earliest start 15 minutes out. When nothing waits, the request is cancelled. iOS decides when to run it, so Spark promises nothing about timing, and the woken app does one thing: it drains the outbox (the third code block). The identifier and the fetch background mode live in the shipped Info.plist, and a test reads that plist.
What waits is visible. The icon's badge is the number of entries not yet settled. One notification is rewritten from the outbox every time: due once the oldest capture has waited 15 minutes (at once if it already has), and removed when nothing waits. Permission is asked in the foreground the first time a pass leaves captures behind, when the question means something. A row that has waited says Waiting since with the month, day and clock, and a row that is sending right now never does.
The words come from one function (the first code block). Take a synthetic trip, Kyoto 2027. A photo kept at 2027-04-03T12:00:00+09:00 at a station café never leaves the phone. Fifteen minutes later the notification reads A capture has not reached Pippa, with the body Taken 04-03 12:00 at a station café, it is still on this phone. Open Spark to send it. If the engine had refused it instead, the body would say so and point at sending it again or discarding it. A refused entry is still waiting from Dad's point of view, so it counts.
Why 15 minutes? A copy over slow mobile data takes a minute or two. A capture still on the phone a quarter of an hour after it was kept is no longer on its way. It is waiting, and that is when it deserves a word.
Arriving as the moment it was
Being sent late is only half the harm. The other half is being read as now. The phone already keeps the capture truth minted at the shutter: the instant with its UTC offset, the time zone and the local date. That truth goes up with the moment's record, never the time of the send. The engine half is taught in the Pippa Reads the Photo track: every practical, explain, card and relay message says when and where its moment was taken, and a moment received more than 30 minutes after it was taken says it was delivered late. Together, a capture that waited three days arrives as a three-day-old moment, and I read it that way.
Principle: A queue that keeps things safe owes the user two more things: a visible count of what it holds, and the original time of each item when it finally lets go.
A camera still keeps its truth
The family rule on photos is Dad's: every piece of metadata stays on private surfaces and is stripped only at publication. The kit's downscale already carries an original's EXIF into the 2048 px copy. A camera still from the in-app camera had a different gap: the picker hands the app a decoded image, and its JPEG carries no EXIF at all.
So PhotoMetadata.filled writes the moment's own truth into what is missing (the second code block), on the original and on Pippa's copy. The capture instant becomes DateTimeOriginal and DateTimeDigitized with their offsets. In the Kyoto example, 2027-04-03T12:00:00+09:00 becomes 2027:04:03 12:00:00 and +09:00, and a fix of 35.0, 135.7 becomes latitude 35.0 N and longitude 135.7 E, with the accuracy when the phone has one.
One rule guards against a quiet lie. A photo that already has a time of its own was taken somewhere else, earlier: a library pick or a share. Where Dad is now is not where that photo was, so it keeps its fields and takes no place from the present. The write is lossless: ImageIO copies the image data with the new metadata merged in, never re-encoding it, and anything it cannot write back returns the bytes unchanged. Seven tests in WaitingCaptureTests cover these rules, from the plist to the notice to a kept still reaching Pippa with its truth.
Code
cwkSpark, clients/apple/SparkMobile/Sources/OutboxWaiting.swift: the refresh task, the 15-minute threshold and the notice's words (verbatim, lines 17-43)·swift
enum OutboxWaiting {
/// Registered in Info.plist (`BGTaskSchedulerPermittedIdentifiers`).
static let refreshTask = "com.cwk.spark.mobile.outbox"
static let notificationId = "spark-outbox-waiting"
/// A capture still on the phone this long after it was kept is waiting,
/// not on its way: a copy over slow LTE takes a minute or two.
static let waitingAfter: TimeInterval = 15 * 60
/// The words for what waits, oldest first; nil when nothing does.
static func notice(_ entries: [QueuedMoment]) -> (title: String, body: String)? {
let waiting = entries.filter { !$0.state.isSettled }.sorted { $0.createdAt < $1.createdAt }
guard let oldest = waiting.first else { return nil }
let parked = waiting.filter { $0.state == .failedNeedsAttention }
let taken = takenLine(oldest.payload)
let title = waiting.count == 1 ? "A capture has not reached Pippa" : "\(waiting.count) captures have not reached Pippa"
var body = waiting.count == 1
? "Taken \(taken), it is still on this phone."
: "The oldest, taken \(taken), is still on this phone."
if !parked.isEmpty {
body += parked.count == waiting.count
? " The engine refused it; open Spark to send it again or discard it."
: " \(parked.count) of them the engine refused; open Spark to see why."
} else {
body += " Open Spark to send \(waiting.count == 1 ? "it" : "them")."
}
return (title, body)
}
cwkSpark, clients/apple/SparkMobile/Sources/OutboxWaiting.swift: a camera still gets the moment's time and place as EXIF and GPS, losslessly (verbatim, lines 130-173)·swift
/// A camera still keeps what the photo knows about itself (todo #590; Dad's
/// rule: every metadatum stays on private surfaces, stripped only at
/// publication). The picker hands the app a decoded image whose JPEG carries
/// no EXIF at all, so the moment's own truth fills what is missing: when it was
/// taken, with its offset, and where. A field the photo already has (a library
/// pick, a share) is never overwritten. Lossless: the image data is copied, not
/// re-encoded; anything ImageIO cannot write back returns the bytes unchanged.
enum PhotoMetadata {
static func filled(_ data: Data, capturedAt: String, place: PlaceSample?) -> Data {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let type = CGImageSourceGetType(source),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any]
else { return data }
let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any] ?? [:]
let metadata = CGImageMetadataCreateMutable()
var changed = false
func set(_ dictionary: CFString, _ key: CFString, _ value: Any) {
changed = CGImageMetadataSetValueMatchingImageProperty(metadata, dictionary, key, value as CFTypeRef) || changed
}
// A photo with a time of its own was taken elsewhere, earlier (a
// library pick): where Dad is now is not where it was, so only a
// photo without one takes the moment's place.
let ownsTime = exif[kCGImagePropertyExifDateTimeOriginal] != nil
if !ownsTime, let stamp = stampParts(capturedAt) {
set(kCGImagePropertyExifDictionary, kCGImagePropertyExifDateTimeOriginal, stamp.clock)
set(kCGImagePropertyExifDictionary, kCGImagePropertyExifDateTimeDigitized, stamp.clock)
set(kCGImagePropertyExifDictionary, kCGImagePropertyExifOffsetTimeOriginal, stamp.offset)
set(kCGImagePropertyExifDictionary, kCGImagePropertyExifOffsetTimeDigitized, stamp.offset)
}
if !ownsTime, properties[kCGImagePropertyGPSDictionary] == nil, let place {
let gps = kCGImagePropertyGPSDictionary
set(gps, kCGImagePropertyGPSLatitude, abs(place.lat))
set(gps, kCGImagePropertyGPSLatitudeRef, place.lat >= 0 ? "N" : "S")
set(gps, kCGImagePropertyGPSLongitude, abs(place.lon))
set(gps, kCGImagePropertyGPSLongitudeRef, place.lon >= 0 ? "E" : "W")
if let accuracy = place.accuracyM { set(gps, kCGImagePropertyGPSHPositioningError, accuracy) }
}
guard changed else { return data }
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(output, type, 1, nil) else { return data }
let options: [CFString: Any] = [kCGImageDestinationMetadata: metadata, kCGImageDestinationMergeMetadata: true]
guard CGImageDestinationCopyImageSource(destination, source, options as CFDictionary, nil) else { return data }
return output as Data
}
cwkSpark, clients/apple/SparkMobile/Sources/SparkMobileApp.swift: leaving the app notes what waits, and a refresh wake drains the outbox (verbatim, lines 12-41)·swift
var body: some Scene {
WindowGroup {
if Self.hostingTests {
Color.clear
} else {
RootView()
.id(model.client.endpoint)
.environment(model)
.tint(Palette.ember)
.preferredColorScheme(.dark)
.onOpenURL { model.receive($0) }
.task(id: "\(phase)-\(model.client.endpoint)") {
// Leaving the app: what still waits is counted on the
// icon, notified, and iOS is asked to wake Spark to
// send it (todo #590).
guard phase == .active else {
if phase == .background { await model.noteWaiting() }
return
}
while !Task.isCancelled {
await model.syncOffline()
do { try await Task.sleep(for: .seconds(60)) } catch { return }
}
}
}
}
.backgroundTask(.appRefresh(OutboxWaiting.refreshTask)) {
await model.drainWhileAway()
}
}
Add a waiting notice to an app with an offline queue. Write one pure function that takes the queue and the current time and returns the badge count, the notification's title and body, and the moment it is due. Test it with a frozen clock: an empty queue, one item younger than your threshold, one older, and one the server refused. Then make the screen and the notification both read from that function.
Hint
Keep the system calls out of the function so the tests need no device. Spark's notice and since take entries and a time and return plain strings; only show and scheduleRefresh touch iOS, and they rewrite everything from the queue each time instead of adding to what was there.
Progress
Progress is local-only — sign in to sync across devices.