From d748167f0e1671c78e9813a8e051eb4610cbd333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6ttger?= Date: Sun, 25 Jan 2026 20:31:34 +0100 Subject: [PATCH] Refactoring --- .../Connection/InboundNearbyConnection.swift | 4 +- NearbyShare/Util/EXIFUtils.swift | 90 ------------------- QuickDrop.xcodeproj/project.pbxproj | 14 --- .../xcshareddata/swiftpm/Package.resolved | 2 +- QuickDropiOS/View/PhotoManager.swift | 64 ++++--------- 5 files changed, 18 insertions(+), 156 deletions(-) delete mode 100644 NearbyShare/Util/EXIFUtils.swift diff --git a/NearbyShare/Connection/InboundNearbyConnection.swift b/NearbyShare/Connection/InboundNearbyConnection.swift index 35f53b3..0a8ba3f 100644 --- a/NearbyShare/Connection/InboundNearbyConnection.swift +++ b/NearbyShare/Connection/InboundNearbyConnection.swift @@ -169,7 +169,7 @@ class InboundNearbyConnection: NearbyConnection { fileInfo.progress?.unpublish() #endif downloadedFiles.append(fileInfo.destinationURL) - EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) + EXIFUtils.applyTimestamps(at: fileInfo.destinationURL) filesToBeReceived.removeValue(forKey: id) @@ -223,7 +223,7 @@ class InboundNearbyConnection: NearbyConnection { #endif filesToBeReceived.removeValue(forKey: id) downloadedFiles.append(fileInfo.destinationURL) - EXIFUtils.applyEXIFTimestamps(at: fileInfo.destinationURL) + EXIFUtils.applyTimestamps(at: fileInfo.destinationURL) if filesToBeReceived.isEmpty { log("[InboundNearbyConnection \(self.id)] Received file payload. Disconnecting...") diff --git a/NearbyShare/Util/EXIFUtils.swift b/NearbyShare/Util/EXIFUtils.swift deleted file mode 100644 index bde8810..0000000 --- a/NearbyShare/Util/EXIFUtils.swift +++ /dev/null @@ -1,90 +0,0 @@ -// -// EXIFUtils.swift -// QuickDrop -// -// Created by Leon Böttger on 10.11.25. -// - -import Foundation -import ImageIO -import LUI - -struct EXIFUtils { - - /// Applies EXIF timestamps to a given file or directory. - static func applyEXIFTimestamps(at url: URL) { - let fm = FileManager.default - var isDir: ObjCBool = false - guard fm.fileExists(atPath: url.path, isDirectory: &isDir) else { return } - - if isDir.boolValue { - // It's a directory — enumerate files - if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) { - for case let fileURL as URL in enumerator { - guard (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]))?.isRegularFile == true else { continue } - applyEXIFTimestamp(to: fileURL) - } - } - } else { - // It's a single file - applyEXIFTimestamp(to: url) - } - } - - - /// Applies EXIF timestamp (if available) to a single file - private static func applyEXIFTimestamp(to fileURL: URL) { - guard let exifDate = exifOriginalDate(from: fileURL) else { - return - } - - do { - // Set the "creation date" - try FileManager.default.setAttributes([.creationDate: exifDate], ofItemAtPath: fileURL.path) - } - catch { - log("[EXIFUtils] Failed to set file attributes for \(fileURL.path): \(error)") - } - } - - - /// Returns the EXIF original date of an image file, adjusted to the current time zone. - static func exifOriginalDate(from url: URL) -> Date? { - guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), - let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any], - let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any], - let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String - else { - return nil - } - - let offsetString = exif[kCGImagePropertyExifOffsetTimeOriginal] as? String - - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.calendar = Calendar(identifier: .gregorian) - formatter.dateFormat = "yyyy:MM:dd HH:mm:ss" - - let offsetSeconds = offsetString.flatMap(secondsFromOffsetString) - if let seconds = offsetSeconds { - formatter.timeZone = TimeZone(secondsFromGMT: seconds) - } else { - // Fallback: treat as device local - formatter.timeZone = TimeZone.current - } - - return formatter.date(from: dateString) - } - - - static private func secondsFromOffsetString(_ s: String) -> Int? { - // Expect formats like "+02:00" or "-05:30" - guard s.count == 6, (s.first == "+" || s.first == "-") else { return nil } - let sign = s.first == "-" ? -1 : 1 - let parts = s.dropFirst().split(separator: ":") - guard parts.count == 2, - let hours = Int(parts[0]), - let minutes = Int(parts[1]) else { return nil } - return sign * (hours * 3600 + minutes * 60) - } -} diff --git a/QuickDrop.xcodeproj/project.pbxproj b/QuickDrop.xcodeproj/project.pbxproj index 6898ad7..c979e7c 100644 --- a/QuickDrop.xcodeproj/project.pbxproj +++ b/QuickDrop.xcodeproj/project.pbxproj @@ -76,7 +76,6 @@ EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB96ED8A2D7F7CF4004B3FF1 /* SmallSheetView.swift */; }; EB3B9F1D2EF7150400D6704E /* License.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB886E662E4F2F7C0052B7D9 /* License.swift */; }; EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54132D78E9E400989564 /* LicenseWindow.swift */; }; - EB3B9F1F2EF7150400D6704E /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A1129E0BF5100A442DA /* AppDelegate.swift */; }; EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB6F62D62D91F4AC00534EC2 /* SettingsView.swift */; }; EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB7F54112D78E95C00989564 /* TutorialView.swift */; }; @@ -123,7 +122,6 @@ EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2929E18CB500A442DA /* wire_format.pb.swift */; }; EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2829E18CB500A442DA /* ukey.pb.swift */; }; EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DA9A2A29E18CB500A442DA /* device_to_device_messages.pb.swift */; }; - EB3B9F5E2EF7151900D6704E /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB950B732E520954002D68FA /* Attachments.swift */; }; EB3B9F602EF7151900D6704E /* Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB1325D22E701D8900FD749C /* Settings.swift */; }; EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB16B7762E59BA9F007D9792 /* AppGroup.swift */; }; @@ -160,10 +158,6 @@ EB3B9F952EF715A000D6704E /* QRCode in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F942EF715A000D6704E /* QRCode */; }; EB3B9F972EF715A000D6704E /* LUI in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F962EF715A000D6704E /* LUI */; }; EB3B9F992EF715A000D6704E /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB3B9F982EF715A000D6704E /* ZIPFoundation */; }; - EB4F9D1F2EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; - EB4F9D202EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; - EB4F9D212EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; - EB4F9D222EC26C8100A74A0F /* EXIFUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */; }; EB4F9D252EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; }; EB4F9D272EC27CFB00A74A0F /* FileManager+DocumentsOpener.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */; }; EB53D8C42EF496AB004C95BA /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = EB53D8C32EF496AB004C95BA /* ZIPFoundation */; }; @@ -514,7 +508,6 @@ EB3B9F492EF7150400D6704E /* QuickDrop copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDrop copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDrop copy-Info.plist"; sourceTree = ""; }; EB3B9F892EF7151900D6704E /* QuickDropShareExtensionGitHub.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = QuickDropShareExtensionGitHub.appex; sourceTree = BUILT_PRODUCTS_DIR; }; EB3B9F8A2EF7151900D6704E /* QuickDropShareExtension copy-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = "QuickDropShareExtension copy-Info.plist"; path = "/Users/leonboettger/QuickDrop Backup with git/QuickDropShareExtension copy-Info.plist"; sourceTree = ""; }; - EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EXIFUtils.swift; sourceTree = ""; }; EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+DocumentsOpener.swift"; sourceTree = ""; }; EB5C36922E34EBBD0052EFD7 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; EB5C36952E34EBBD0052EFD7 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/ShareViewController.xcstrings; sourceTree = ""; }; @@ -762,7 +755,6 @@ EB16B7762E59BA9F007D9792 /* AppGroup.swift */, EB886E512E4E19390052B7D9 /* HKDF.swift */, EB4F9D232EC27CF000A74A0F /* FileManager+DocumentsOpener.swift */, - EB4F9D1E2EC26C7C00A74A0F /* EXIFUtils.swift */, EB2F9BFA2DA04B67004010A2 /* Helpers.swift */, ); path = Util; @@ -1246,7 +1238,6 @@ EB5C37C72E377A780052EFD7 /* wire_format.pb.swift in Sources */, EB5C37C82E377A780052EFD7 /* ukey.pb.swift in Sources */, EB5C37C92E377A780052EFD7 /* device_to_device_messages.pb.swift in Sources */, - EB4F9D222EC26C8100A74A0F /* EXIFUtils.swift in Sources */, EB950B742E520957002D68FA /* Attachments.swift in Sources */, EB1325D32E701D8E00FD749C /* Settings.swift in Sources */, EB16B7782E59BAA1007D9792 /* AppGroup.swift in Sources */, @@ -1306,7 +1297,6 @@ EB96ED8C2D7F7E7B004B3FF1 /* SmallSheetView.swift in Sources */, EB886E682E4F2F800052B7D9 /* License.swift in Sources */, EB7F54142D78E9E700989564 /* LicenseWindow.swift in Sources */, - EB4F9D202EC26C8100A74A0F /* EXIFUtils.swift in Sources */, 69DA9A1229E0BF5100A442DA /* AppDelegate.swift in Sources */, EB6F62D72D91F4AF00534EC2 /* SettingsView.swift in Sources */, EB7F54122D78E96000989564 /* TutorialView.swift in Sources */, @@ -1361,7 +1351,6 @@ EB3B9F1C2EF7150400D6704E /* SmallSheetView.swift in Sources */, EB3B9F1D2EF7150400D6704E /* License.swift in Sources */, EB3B9F1E2EF7150400D6704E /* LicenseWindow.swift in Sources */, - EB3B9F1F2EF7150400D6704E /* EXIFUtils.swift in Sources */, EB3B9F202EF7150400D6704E /* AppDelegate.swift in Sources */, EB3B9F212EF7150400D6704E /* SettingsView.swift in Sources */, EB3B9F222EF7150400D6704E /* TutorialView.swift in Sources */, @@ -1403,7 +1392,6 @@ EB3B9F5B2EF7151900D6704E /* wire_format.pb.swift in Sources */, EB3B9F5C2EF7151900D6704E /* ukey.pb.swift in Sources */, EB3B9F5D2EF7151900D6704E /* device_to_device_messages.pb.swift in Sources */, - EB3B9F5E2EF7151900D6704E /* EXIFUtils.swift in Sources */, EB3B9F5F2EF7151900D6704E /* Attachments.swift in Sources */, EB3B9F602EF7151900D6704E /* Settings.swift in Sources */, EB3B9F612EF7151900D6704E /* AppGroup.swift in Sources */, @@ -1453,7 +1441,6 @@ EBBC93B32EC8B76600F541A1 /* LocalNetworkPermissionView.swift in Sources */, EB97A00F2E511124008B8C5C /* ReceiveModel.swift in Sources */, EB5C37FE2E377CE70052EFD7 /* offline_wire_formats.pb.swift in Sources */, - EB4F9D1F2EC26C8100A74A0F /* EXIFUtils.swift in Sources */, EB886E6A2E4F37B90052B7D9 /* ErrorAlertHandler.swift in Sources */, EB886E5E2E4F13EB0052B7D9 /* Zip.swift in Sources */, EB5C383D2E38C2D90052EFD7 /* AudioManager.swift in Sources */, @@ -1489,7 +1476,6 @@ EB950B4B2E520436002D68FA /* offline_wire_formats.pb.swift in Sources */, EB950B4C2E520436002D68FA /* InboundNearbyConnection.swift in Sources */, EB16B6132E58B4B9007D9792 /* IdentityManager.swift in Sources */, - EB4F9D212EC26C8100A74A0F /* EXIFUtils.swift in Sources */, EB950B4D2E520436002D68FA /* NearbyConnection.swift in Sources */, EB950B502E520436002D68FA /* wire_format.pb.swift in Sources */, EB950B582E5205B4002D68FA /* Zip.swift in Sources */, diff --git a/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 09230f6..2e5f32c 100644 --- a/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/QuickDrop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -52,7 +52,7 @@ "location" : "https://github.com/leonboe1/LUI.git", "state" : { "branch" : "main", - "revision" : "e7bb652c44d38f64d7d056723aa5cd01cb3e0a8d" + "revision" : "b58913bbbb648ed24babf30bd8bcdff2157d8d84" } }, { diff --git a/QuickDropiOS/View/PhotoManager.swift b/QuickDropiOS/View/PhotoManager.swift index 278d7a4..b8fb0b1 100644 --- a/QuickDropiOS/View/PhotoManager.swift +++ b/QuickDropiOS/View/PhotoManager.swift @@ -19,7 +19,7 @@ struct PhotoManager { /// Returns if there are photos or videos among the given URLs. static func hasPhotosOrVideos(at urls: [URL]) -> Bool { - urls.contains(where: { isImageFile(at: $0) || isVideoFile(at: $0) }) + urls.contains(where: { EXIFUtils.isImageFile(at: $0) || EXIFUtils.isVideoFile(at: $0) }) } @@ -30,9 +30,6 @@ struct PhotoManager { /// - Throws: `SaveToPhotosError` or underlying URLSession/Photos errors. static func saveMediaToPhotoLibrary(from urls: [URL], openPhotosOnSuccess: Bool = true) async throws { - let candidates = urls.filter { isImageFile(at: $0) || isVideoFile(at: $0) } - guard !candidates.isEmpty else { throw SaveToPhotosError.noSavableContent } - let authStatus = await requestAddOnlyPhotoAuthorization() guard authStatus == .authorized || authStatus == .limited else { throw SaveToPhotosError.permissionDenied @@ -41,16 +38,15 @@ struct PhotoManager { var imageItems: [ImageItem] = [] var videoItems: [VideoItem] = [] - // Extract creation date for image and video - for sourceURL in candidates { - - if isImageFile(at: sourceURL) { - let exifDate = EXIFUtils.exifOriginalDate(from: sourceURL) - imageItems.append(ImageItem(fileURL: sourceURL, creationDate: exifDate)) - } else { - let videoDate = getVideoCreationDate(from: sourceURL) - videoItems.append(VideoItem(fileURL: sourceURL, creationDate: videoDate)) + for sourceURL in urls { + if let (type, date) = EXIFUtils.originalDate(from: sourceURL) { + if type == .image { + imageItems.append(ImageItem(fileURL: sourceURL, creationDate: date)) + } + if type == .video { + videoItems.append(VideoItem(fileURL: sourceURL, creationDate: date)) + } } } @@ -116,40 +112,6 @@ struct PhotoManager { } - private static func isImageFile(at url: URL) -> Bool { - let ext = url.pathExtension.lowercased() - guard let type = UTType(filenameExtension: ext) else { - return false - } - - return type.conforms(to: .image) - } - - - private static func isVideoFile(at url: URL) -> Bool { - let ext = url.pathExtension.lowercased() - guard let type = UTType(filenameExtension: ext) else { - return false - } - // `.movie` is the canonical file-based video type; `.audiovisualContent` - // catches some broader cases if needed. - return type.conforms(to: .movie) || type.conforms(to: .audiovisualContent) - } - - - private static func getVideoCreationDate(from url: URL) -> Date? { - let asset = AVURLAsset(url: url) - // AVMetadataItem that typically contains an ISO‑8601 string - guard let item = asset.creationDate, - let dateString = item.stringValue else { - return nil - } - - let formatter = ISO8601DateFormatter() - return formatter.date(from: dateString) - } - - /// Requests add-only Photos permission private static func requestAddOnlyPhotoAuthorization() async -> PHAuthorizationStatus { await withCheckedContinuation { continuation in @@ -158,8 +120,12 @@ struct PhotoManager { } } } - - +} + + +// MARK: - Private types + +private extension PhotoManager { private enum SaveToPhotosError: Error, LocalizedError { case noSavableContent case permissionDenied