From e00e38505bad5410a351da2cc2b86c8149c20b0a Mon Sep 17 00:00:00 2001 From: Esteve Castells Date: Fri, 5 Jun 2026 22:09:15 +0200 Subject: [PATCH] SaveToCameraRoll: stop silently dropping media on save When saving a photo/video from a chat to the iOS photo library, the save could intermittently "glitch" and never appear in Photos. The authorized save closure swallowed every failure on the way to PHPhotoLibrary: - A failed AVAssetExportSession in the motion-photo path hit `guard success else { return }` and never called putCompletion(), so the signal hung forever and nothing was saved. - `addAssetIdentifierToJPEG(...)!` force-unwrapped, crashing on any image the re-encode could not handle. - The completion handler always reported success even when the change block staged nothing or PhotoKit returned an error, so a failed save was indistinguishable from a successful one. - Videos were always copied to a hardcoded ".mp4" temp path, discarding the source container's real extension. Make the path honest and resilient: - Preserve the source file's extension for the temp copy. - Replace the force-unwrap with a guard. - On export failure or a failed paired (motion-photo) write, fall back to saving the plain still image so it still lands, and always finish the signal so the progress HUD dismisses. - Track whether a resource was actually staged and log when media did not land, instead of reporting an unconditional success. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/SaveToCameraRoll.swift | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift index 2f8b30b155a..413c8d92e0c 100644 --- a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift +++ b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift @@ -152,49 +152,90 @@ public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourc return } - let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4" - if isImage, let videoData, let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) { - let id = UUID().uuidString + let id = UUID().uuidString - let jpegWithID = addAssetIdentifierToJPEG(imageData, assetIdentifier: id)! - let outputVideoURL = URL(fileURLWithPath: NSTemporaryDirectory() + "\(id).mov") - - try? FileManager.default.copyItem(atPath: videoData.path, toPath: tempVideoPath) - - addAssetIdentifierToVideo(inputURL: URL(fileURLWithPath: tempVideoPath), outputURL: outputVideoURL, assetIdentifier: id) { success in - guard success else { return } - - PHPhotoLibrary.shared().performChanges({ - let request = PHAssetCreationRequest.forAsset() + // Builds a temporary path that preserves the source file's extension. Copying a + // video to a hardcoded ".mp4" path makes `creationRequestForAssetFromVideo` reject + // any container that isn't actually mp4 (e.g. .mov), so the asset silently never + // appears in Photos. Falling back to "mov" matches the most common Telegram video. + func temporaryPath(preservingExtensionOf sourcePath: String) -> String { + let pathExtension = (sourcePath as NSString).pathExtension + return NSTemporaryDirectory() + "\(UUID().uuidString)." + (pathExtension.isEmpty ? "mov" : pathExtension) + } - request.addResource(with: .photo, data: jpegWithID, options: nil) - request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil) - }, completionHandler: { _, error in - let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) - subscriber.putNext(1.0) - subscriber.putCompletion() - }) - } - } else { + // Saves the plain photo or video. This is both the normal path for non-motion media + // and the fallback when a motion-photo write fails, so it always finishes the signal + // and the saved media still lands in Photos. + func savePlainMedia() { + let videoTempPath = temporaryPath(preservingExtensionOf: mainData.path) + var didStageResource = false PHPhotoLibrary.shared().performChanges({ if isImage { if let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) { PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: nil) + didStageResource = true } } else { - if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: tempVideoPath) { - PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath)) + if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: videoTempPath) { + PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: videoTempPath)) + didStageResource = true } } - }, completionHandler: { _, error in + }, completionHandler: { success, error in + // PhotoKit reports success even when the change block stages nothing, so a + // failed read/copy would otherwise look like a successful save. Log when the + // media did not actually land so the failure is diagnosable. if let error { - print("\(error)") + print("SaveToCameraRoll: failed to save media: \(error)") + } else if !didStageResource || !success { + print("SaveToCameraRoll: media was not saved (didStageResource: \(didStageResource), success: \(success))") } - let _ = try? FileManager.default.removeItem(atPath: tempVideoPath) + let _ = try? FileManager.default.removeItem(atPath: videoTempPath) subscriber.putNext(1.0) subscriber.putCompletion() }) } + + if isImage, let videoData, + let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)), + let jpegWithID = addAssetIdentifierToJPEG(imageData, assetIdentifier: id) { + let pairedVideoTempPath = temporaryPath(preservingExtensionOf: videoData.path) + let outputVideoURL = URL(fileURLWithPath: NSTemporaryDirectory() + "\(id).mov") + + let _ = try? FileManager.default.copyItem(atPath: videoData.path, toPath: pairedVideoTempPath) + + addAssetIdentifierToVideo(inputURL: URL(fileURLWithPath: pairedVideoTempPath), outputURL: outputVideoURL, assetIdentifier: id) { success in + let _ = try? FileManager.default.removeItem(atPath: pairedVideoTempPath) + guard success else { + // The export that embeds the motion-photo identifier failed. Previously + // the signal was left hanging forever and nothing was saved; instead fall + // back to saving the still image so it still lands in Photos. + savePlainMedia() + return + } + + PHPhotoLibrary.shared().performChanges({ + let request = PHAssetCreationRequest.forAsset() + + request.addResource(with: .photo, data: jpegWithID, options: nil) + request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil) + }, completionHandler: { success, error in + let _ = try? FileManager.default.removeItem(at: outputVideoURL) + if success { + subscriber.putNext(1.0) + subscriber.putCompletion() + } else { + if let error { + print("SaveToCameraRoll: failed to save motion photo: \(error)") + } + // Saving the paired photo+video failed; fall back to the still image. + savePlainMedia() + } + }) + } + } else { + savePlainMedia() + } }) return ActionDisposable {