Skip to content

Commit 59e20e2

Browse files
Fix the Lock Screen camera's thumbnails and its shots not arriving
On the first phone test, the Lock Screen camera's stack showed each shot sideways and uncropped, and REVEAL opened the app with nothing to deal out. The thumbnail came from UIKit, which ignored the photo's rotation tag; it's now made the way the app reads photos, upright and cut to the brackets. The app checked the camera's folders once as it came up; it now follows the system's updates to them, and clears sessions where nothing was taken. Each shot also records its camera angle, for debug mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 254df07 commit 59e20e2

5 files changed

Lines changed: 50 additions & 4 deletions

File tree

‎Shared/LockedShot.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ struct LockedShot: Codable {
1111
var viewSize: CGSize?
1212
var latitude: Double?
1313
var longitude: Double?
14+
/// The camera's rotation for the shot, in degrees (90 upright), for debug mode.
15+
var angle: Double?
1416

1517
func save(_ photo: Data, in folder: URL) throws {
1618
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
@@ -20,6 +22,11 @@ struct LockedShot: Codable {
2022
try JSONEncoder().encode(self).write(to: folder.appendingPathComponent("\(id).json"), options: .atomic)
2123
}
2224

25+
static func hasShots(in folder: URL) -> Bool {
26+
((try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? [])
27+
.contains { $0.pathExtension == "jpg" }
28+
}
29+
2330
/// Every shot in a session's folder, oldest first.
2431
static func all(in folder: URL) -> [(photo: Data, shot: LockedShot)] {
2532
let files = (try? FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)) ?? []

‎Tabor.xcodeproj/project.pbxproj‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@
126126
membershipExceptions = (
127127
Camera/CameraModel.swift,
128128
Camera/ViewfinderBrackets.swift,
129+
Core/Viewfinder.swift,
129130
);
130131
target = A10000000000000000000090 /* TaborCapture */;
131132
};

‎Tabor/App/TaborApp.swift‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,13 @@ final class Router {
9393
let burst = BurstQueue()
9494
/// Goes up when the Lock Screen camera has left shots: CATCH takes them as a burst.
9595
var lockedShots = 0
96+
/// The Lock Screen camera's session folders, kept from the system's updates: on the first
97+
/// phone test, checking its list once as the app came up didn't find the shots.
98+
var lockedFolders: [URL] = []
9699

97100
/// Shots from the Lock Screen camera (#107), once the phone is unlocked.
98101
func catchLocked() {
99-
guard !LockedCameraCaptureManager.shared.sessionContentURLs.isEmpty else { return }
102+
guard lockedFolders.contains(where: LockedShot.hasShots) else { return }
100103
tab = .catchTab
101104
bookPath.removeAll()
102105
lockedShots += 1
@@ -262,6 +265,20 @@ struct RootView: View {
262265
// They've found the control on their own: no need to tip them off about it.
263266
UserDefaults.standard.set(true, forKey: CatchControlTip.seenKey)
264267
}
268+
.task {
269+
for await update in LockedCameraCaptureManager.shared.sessionContentUpdates {
270+
switch update {
271+
case .initial(let urls): router.lockedFolders = urls
272+
case .added(let url): router.lockedFolders.append(url)
273+
case .removed(let url): router.lockedFolders.removeAll { $0 == url }
274+
}
275+
// The camera opened and nothing taken: nothing to keep.
276+
for url in router.lockedFolders where !LockedShot.hasShots(in: url) {
277+
try? await LockedCameraCaptureManager.shared.invalidateSessionContent(at: url)
278+
}
279+
router.catchLocked()
280+
}
281+
}
265282
// REVEAL in the Lock Screen camera, once Face ID lets it through.
266283
.onContinueUserActivity(NSUserActivityTypeLockedCameraCapture) { _ in router.catchLocked() }
267284
.onReceive(NotificationCenter.default.publisher(for: .taborOpenHunt)) { _ in

‎Tabor/Screens/CatchView.swift‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,7 +873,7 @@ struct CatchView: View {
873873
/// The Lock Screen camera's shots, read and dealt out as cards. Its folders are only
874874
/// cleared once the cards are ready, so a crash before then leaves the shots for next time.
875875
private func takeLocked() {
876-
let folders = LockedCameraCaptureManager.shared.sessionContentURLs
876+
let folders = router.lockedFolders
877877
guard !folders.isEmpty, draft == nil, cards == nil, !finishing else { return }
878878
Task {
879879
finishing = true
@@ -896,6 +896,7 @@ struct CatchView: View {
896896
record?.attach(original: photo)
897897
record?.update { $0.crop = "brackets \(crop.integral) in \(size.width)×\(size.height) viewfinder" }
898898
}
899+
if let angle = shot.angle { record?.update { $0.captureAngle = angle } }
899900
let age = Date().timeIntervalSince(shot.date)
900901
// Taken a moment ago: you're still about where the shot was, and so are the vehicles.
901902
let nearby = age < 180 && live.fresh() != nil ? live.nearby : []

‎TaborCapture/CaptureView.swift‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import AVKit
22
import CoreLocation
3+
import ImageIO
34
import LockedCameraCapture
45
import SwiftUI
56

@@ -214,7 +215,7 @@ struct CaptureView: View {
214215
withAnimation(.easeOut(duration: 0.35)) { flash = false }
215216
guard let data else { return }
216217
var shot = LockedShot(date: Date(), latitude: place.latest?.coordinate.latitude,
217-
longitude: place.latest?.coordinate.longitude)
218+
longitude: place.latest?.coordinate.longitude, angle: Double(camera.captureAngle))
218219
// Held upright, the app crops to the brackets as CATCH does; sideways it keeps it all.
219220
if camera.captureAngle == 90, bracketGlobal.width > 0, finderFrame.width > 0 {
220221
let brackets = bracketGlobal.offsetBy(dx: -finderFrame.minX, dy: -finderFrame.minY)
@@ -226,10 +227,29 @@ struct CaptureView: View {
226227
} catch {
227228
return UINotificationFeedbackGenerator().notificationOccurred(.error)
228229
}
229-
let thumb = await Task.detached { UIImage(data: data)?.preparingThumbnail(of: CGSize(width: 120, height: 120)) }.value
230+
let crop = shot.crop, size = shot.viewSize
231+
let thumb = await Task.detached { Self.thumbnail(data, crop: crop, viewSize: size) }.value
230232
if let thumb { withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { thumbs.append(thumb) } }
231233
}
232234

235+
/// What the app will read: upright (UIKit's thumbnails ignored the photo's rotation tag)
236+
/// and cut to the brackets.
237+
nonisolated private static func thumbnail(_ data: Data, crop: CGRect?, viewSize: CGSize?) -> UIImage? {
238+
guard let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }
239+
let opts: [CFString: Any] = [
240+
kCGImageSourceCreateThumbnailFromImageAlways: true,
241+
kCGImageSourceCreateThumbnailWithTransform: true,
242+
kCGImageSourceThumbnailMaxPixelSize: 600,
243+
]
244+
guard var image = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) else { return nil }
245+
let w = CGFloat(image.width), h = CGFloat(image.height)
246+
if let crop, let viewSize, let r = Viewfinder.region(of: crop, in: viewSize, imageSize: CGSize(width: w, height: h)),
247+
let cut = image.cropping(to: CGRect(x: r.minX * w, y: r.minY * h, width: r.width * w, height: r.height * h).integral) {
248+
image = cut
249+
}
250+
return UIImage(cgImage: image)
251+
}
252+
233253
/// Unlocks the phone (Face ID) and opens the app, which picks the shots up.
234254
private func reveal() {
235255
guard !opening else { return }

0 commit comments

Comments
 (0)