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

The Red Band

~15 min · camera, error-handling, http-status, privacy-indicator

Level 0Idle Phone
0 XP0/45 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A band the model owns

While my camera runs, the top of the face is a full-width red band in heavy white type: CAMERA ON · FRONT, followed by the name of the brain that asked when the request named one (CAMERA ON · FRONT · ChatGPT). The architecture says it in one line: the red band is up for the whole capture. The first code block is how the app keeps that promise without having to remember it. BodyModel sets capturing to the camera and, on the very next line, schedules capturing = nil with defer. The face draws the band whenever capturing is set. A defer runs on every way out of the function: a picture returned, a refusal thrown, a deadline that expired, an error from AVFoundation. Nobody has to write "and lower the band" on each exit path, so nobody can forget one.

While the band is up, the rest of the face says the same thing in other ways. My portrait switches to its looking frame, and the line at the foot names the brain at full brightness instead of dimmed. Notice the order inside photo: the watch for the still's GPS runs before capturing is set. A few seconds of looking for a place is not looking through a lens, and the band is about the lens. The band also carries the accessibility identifier camera-on-band (third block), so the phone hands and a UI test can find it the way a person sees it, at the top of the face, above everything else.

Refusals that name a reason

A capture can be refused before any session starts, and each refusal is a status with its reason in words (Refusal(status, reason), answered as JSON with an error field):

StatusWhenWhere it is decided
409the app is not in front, and iOS keeps the camera off for a backgrounded appBodyModel, guard active, before anything else
403no camera grant; a person allows it once, on the first prompt or in SettingsCameraRig.requireGrant
423another capture is runningCameraRig.claim, under a lock
503 / 504no such camera, or it would not open; a deadline ran out, naming what it waited forthe rig's session setup; Deadline.run

Understand the 409 first. iOS does not let a backgrounded app use the camera at all (an interrupted session reports videoDeviceNotAvailableInBackground), so a body is a foreground kiosk, and the honest answer to a request that arrives while it sits behind another app is "bring me to the front first", not a black frame. The 403 points at a person on purpose. The app asks for the camera once, at its first launch, and never routes around a denial; a missing grant also shows on the face in amber, naming what to allow in Settings. The 423 is what claim in the second code block throws: one boolean under an NSLock, set and cleared by the same request, so a still asked for during a clip waits for nobody and is told why.

Oriel refuses first, and keeps the words

Most of these refusals never reach the body. Oriel probes each body for its status every 10 s, and before it sends a capture it reads the last answer: a body silent for more than 30 s, not in front, without the camera grant, or without the named camera is refused right there in a sentence of the same kind. When the body does refuse, Oriel's link keeps the reason and folds the status. Any 4xx from the body reaches the caller as 409 and any 5xx as 503, with a message like "name refused a front photo: the camera is busy with another capture". The brain that asked reads the sentence, not the number, so the sentence is the contract.

Each side also keeps its own named clock. On the body a still waits at most 10 s, and a clip its length plus the 0.8 s settle plus 10 s. On Oriel a photo waits 20 s and a clip its length plus 20 s. The device gives up first and says what it was waiting for; Oriel's longer clock is the net for a body that never answers at all.

Where the band and the camera could disagree

Read the deadline path closely and you will find the one gap the band cannot cover. When Deadline.run expires, it cancels the capture task, Once resumes with a cancellation, the request answers 504, release frees the claim and the defer lowers the band. All of that is right for the request. The capture session itself, though, is stopped by its delegate's completion on the rig's queue, so if AVFoundation never called back, nothing on that path would stop it, or delete a clip's temporary file (lesson 4). That is why Oriel's architecture calls the hardware's own tally the second safety: iOS lights its own camera indicator whenever a camera streams, whatever any app draws. The band is my promise about the request. The system indicator is Apple's promise about the sensor. Keep both, and never let your own indicator be the only one.

Code

cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/BodyModel.swift: the band goes up with the capture and comes down on every way out (verbatim, lines 230-257)·swift
  func photo(_ which: Camera) async throws -> Capture {
    guard active else { throw Refusal.notActive("camera") }
    // The still carries its place: a fix from the last five minutes, else a
    // short watch taken for this request (a few seconds at most).
    refreshGrants()
    let fix = await watchForFix(seconds: Deadline.photoFix, maxAge: 300)
    let place = fix.map {
      CLLocation(coordinate: CLLocationCoordinate2D(latitude: $0.lat, longitude: $0.lon),
                 altitude: $0.altitudeM ?? 0, horizontalAccuracy: $0.accuracyM,
                 verticalAccuracy: $0.altitudeM == nil ? -1 : ($0.altitudeAccuracyM ?? 10), timestamp: $0.sampledAt)
    }
    capturing = which
    defer { capturing = nil }
    let rig = camera
    return try await Deadline.run(Deadline.photo, "the \(which.rawValue) camera's still") {
      try await rig.photo(which, location: place)
    }
  }

  func clip(_ which: Camera, seconds: Double) async throws -> Capture {
    guard active else { throw Refusal.notActive("camera") }
    capturing = which
    defer { capturing = nil }
    let rig = camera
    return try await Deadline.run(seconds + Deadline.exposureSettle + Deadline.clipSlack, "the \(which.rawValue) clip") {
      try await rig.clip(which, seconds: seconds)
    }
  }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Body/CameraRig.swift: one capture at a time, and the grant a person gives (verbatim, lines 45-63)·swift
  private func claim() throws {
    try lock.withLock {
      guard !busy else { throw Refusal(423, "the camera is busy with another capture") }
      busy = true
    }
  }

  private func release() { lock.withLock { busy = false } }

  private func hold(_ object: AnyObject) { lock.withLock { holding[ObjectIdentifier(object)] = object } }

  private func drop(_ object: AnyObject) { _ = lock.withLock { holding.removeValue(forKey: ObjectIdentifier(object)) } }

  private static func requireGrant() throws {
    let grant = Self.grant()
    guard grant == "authorized" else {
      throw Refusal(403, "the camera is not allowed for Cordelia (\(grant)); a person allows it once in Settings")
    }
  }
cwkCordelia, clients/apple/CordeliaMobile/Sources/Views/FaceView.swift: the band itself (verbatim, lines 267-276)·swift
  private func band(_ camera: Camera) -> some View {
    Text("CAMERA ON · \(camera.rawValue.uppercased())" + (model.user.map { " · \($0.name)" } ?? ""))
      .font(.title2.weight(.heavy))
      .foregroundStyle(.white)
      .frame(maxWidth: .infinity)
      .padding(.top, 14)
      .padding(.bottom, 12)
      .background(Color(red: 0.86, green: 0.1, blue: 0.12))
      .accessibilityIdentifier("camera-on-band")
  }

External links

Exercise

Take a capture function in your own app and give it an in-use indicator bound to the function's scope: raised and its lowering scheduled in the same breath. Add three refusals that each name their reason (not in front, not granted, busy), checked before any hardware is touched. Then write a test with a fake camera that fails at each stage in turn (refusal, thrown error, a callback that never arrives under a deadline) and assert after every case that the indicator is down and the claim is free.
Hint
The callback that never arrives is the interesting case. Your deadline has to resume the waiting caller on cancellation, or the test hangs instead of failing. Once it passes, ask what is still running on the hardware side after the deadline fired, and write that answer down next to the test.

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 in — Please sign in to comment.

No comments yet — be the first.