Add didCaptureMediaURL callback to bypass camera roll - #383
Conversation
When didCaptureMediaURL is set on TLPhotosPickerViewController, captured photos and videos are written to a temp file and the URL is passed to the callback instead of being saved to the photo library. Existing behaviour is unchanged when the callback is not set. Signed-off-by: Rasmus Wøldike <[email protected]>
Signed-off-by: Rasmus Wøldike <[email protected]>
|
@Rasmussw Thanks for the PR — the One thing I'd like your opinion on, around error handling: try? FileManager.default.copyItem(at: videoURL, to: destURL)
bypass(destURL)
Still, when it does fail, the caller receives a URL pointing to a file that doesn't exist, with no signal. Two ways to address this — curious which you prefer: A. Silently drop on failure — keep the API as-is, only invoke the callback when the write succeeded: if (try? FileManager.default.copyItem(at: videoURL, to: destURL)) != nil {
bypass(destURL)
}B. Surface the error — change the signature to pass the error through: var didCaptureMediaURL: ((Result<URL, Error>) -> Void)?Given how rare the failure path is, A feels like the right tradeoff to me — preserves the simple API and just enforces the minimum contract that "if you got a URL, the file is there." But if you have a use case where the caller benefits from knowing about the failure, B is reasonable too. What do you think? |
|
One more thought, on the photo-encoding side — I looked at how the rest of the SDK handles this and there's a small mismatch worth flagging: let data = image.jpegData(compressionQuality: 0.9)A few concerns: 1. Original format and metadata are lost. 2. Inconsistent with the SDK's existing convention. The rest of the SDK preserves original bytes via Suggested approach — prefer if let originalURL = info[.imageURL] as? URL {
let destURL = tempDir.appendingPathComponent(UUID().uuidString + "." + originalURL.pathExtension)
if (try? FileManager.default.copyItem(at: originalURL, to: destURL)) != nil {
bypass(destURL)
}
} else if let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 1) {
let destURL = tempDir.appendingPathComponent(UUID().uuidString + ".jpg")
if (try? data.write(to: destURL)) != nil {
bypass(destURL)
}
}To make The result is that the bypass output matches what the app would get if it read the file from the photo library — no surprise format changes, no metadata loss. Curious what you think. |
|
@Rasmussw friendly ping — no rush, just wanted to make sure the comments above didn't get lost. Happy to discuss the error-handling and encoding approaches whenever you have time. |
|
@tilltue Sorry for the slow reply! Thanks for the review. Error handling: Agreed, I think option A is the right call. Photo encoding: Great catch. info[.imageURL] with imageExportPreset = .compatible is clearly the better approach, metadata is preserved and it stays consistent with the rest of the SDK. I'll implement your suggested pattern and will push a new commit shortly. |
- Use info[.imageURL] with imageExportPreset .compatible to preserve EXIF/GPS metadata - Only invoke callback on successful file copy (no silent error swallowing)
|
@Rasmussw This looks great — both points addressed exactly as discussed. The error handling now correctly invokes the callback only on success, and using Thanks so much for the contribution and for taking the time to iterate on the review feedback — really appreciate the care you put into this. I'll merge it and include it in the next release. |
|
@tilltue Perfect! when do you plan to make the new release? |
|
@Rasmussw It's out now! 🎉 Just released 2.1.19, which includes your
Thanks again for the great contribution and for patiently iterating through the review feedback — really appreciate the care you put into the error handling and the |
|
Hi @tilltue Thanks for the quick release of 2.1.19! Unfortunately I've run into an issue with the timing change from the "fix: avoid duplicate camera picker dismiss" commit. Moving bypass into the picker.dismiss completion handler breaks the use case where the caller needs to dismiss TLPhotosPickerViewController and present a new view controller from within the closure. When it looks like this: pickerVC?.didCaptureMediaURL = { [weak pickerVC] url in With the original timing (bypass called synchronously after picker.dismiss starts), this worked correctly. With 2.1.19, bypass is called from inside a dismiss completion handler, and calling another dismiss from there is unreliable on iOS, the upload view is never presented and the user ends up back on the photo picker screen. Also worth noting, the withTLPHAssets completion already handles the dismiss internally before calling the callback, so the caller doesn't need to worry about it. It would be consistent if didCaptureMediaURL followed the same pattern, TLPhotoPicker dismisses itself first, then calls the closure, so the caller only needs to present their next viewcontroller. Could you clarify what the intended usage pattern is when the caller needs to dismiss TLPhotosPickerViewController from within the closure? Or would it be possible to call bypass before picker.dismiss to avoid this issue? Thanks again! Best regards, |
|
@Rasmussw I checked the call order and this is a valid regression from the 2.1.19 follow-up fix. The duplicate dismiss fix moved One detail: the I opened #385 to restore the original callback timing while still keeping the camera picker dismiss call single. The callback now runs outside the dismiss completion again and still only fires when the temp-file copy succeeds. I also updated the docs PR (#384) so it no longer says the callback runs after the camera picker dismisses. |
|
@Rasmussw The fix is released in 2.1.20. 🎉
Available now:
Thank you so much for the detailed regression report — the exact reproduction case (dismiss + present from within the closure) made it really easy to pinpoint and confirm the fix. Genuinely appreciate the careful follow-through on this. 🙏 |
|
@tilltue Perfect, thanks for the quick reply and the fix! Much appreciated. |
|
Hi @tilltue I've been testing further and found that there are still two issues with the current 2.1.20 implementation. Bypass is called while the UIImagePickerController dismiss animation is still ongoing, so calling dismiss on TLPhotosPickerViewController from within the closure is ignored by iOS. TLPhotosPickerViewController doesn't dismiss itself, leaving the caller responsible, which is inconsistent with how withTLPHAssets already works. I've implemented a fix in my fork that addresses both: https://github.com/Rasmussw/TLPhotoPicker TLCameraService: copy the file first, then call bypass in the picker.dismiss completion so the camera picker is fully dismissed before the callback fires. TLPhotosPickerViewController: wrap didCaptureMediaURL to self-dismiss before invoking the caller's closure. I've tested it and it works correctly. Would you be open to merging these changes? Best regards, |
|
Hi @Rasmussw — thanks for digging in further and for the fork. I went through the call ordering again and I agree with both points:
Let's move the One correction on the rationale, just so the contract is documented accurately: Two structural notes before this goes in — could you fold these into the new PR? 1. Keep the public property a plain passthrough; don't wrap the closure in the setter. The setter-wrapping in the fork makes public var didCaptureMediaURL: ((URL) -> Void)? {
get { cameraService.didCaptureMediaURL }
set { cameraService.didCaptureMediaURL = newValue }
}2. Single branch point + self-contained paths in the delegate method. Split func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let bypass = didCaptureMediaURL {
bypassCapturedMedia(picker: picker, info: info, bypass: bypass)
} else {
saveCapturedMediaToLibrary(picker: picker, info: info)
}
}
// Callback registered: copy to a temp file, dismiss the camera picker, then the
// host picker, then deliver the URL. Photo library is never touched.
private func bypassCapturedMedia(picker: UIImagePickerController,
info: [UIImagePickerController.InfoKey: Any],
bypass: @escaping (URL) -> Void) {
let tempDir = FileManager.default.temporaryDirectory
var capturedURL: URL?
if let videoURL = info[.mediaURL] as? URL {
let destURL = tempDir.appendingPathComponent(UUID().uuidString + ".mov")
do { try FileManager.default.copyItem(at: videoURL, to: destURL); capturedURL = destURL } catch {}
} else if let imageURL = info[.imageURL] as? URL {
let ext = imageURL.pathExtension.isEmpty ? "jpg" : imageURL.pathExtension
let destURL = tempDir.appendingPathComponent(UUID().uuidString + "." + ext)
do { try FileManager.default.copyItem(at: imageURL, to: destURL); capturedURL = destURL } catch {}
} else if let image = info[.originalImage] as? UIImage,
let data = image.jpegData(compressionQuality: 1.0) {
let destURL = tempDir.appendingPathComponent(UUID().uuidString + ".jpg")
do { try data.write(to: destURL); capturedURL = destURL } catch {}
}
guard let capturedURL else {
picker.dismiss(animated: true, completion: nil)
return
}
picker.dismiss(animated: true) { [weak self] in
self?.presentingViewController?.dismiss(animated: true) {
bypass(capturedURL)
}
}
}
// Callback not registered: existing behavior — save to the photo library.
private func saveCapturedMediaToLibrary(picker: UIImagePickerController,
info: [UIImagePickerController.InfoKey: Any]) {
picker.dismiss(animated: true, completion: nil)
if let image = info[.originalImage] as? UIImage {
saveCapturedAsset(image: image)
} else if let mediaType = info[.mediaType] as? String {
let isMovieType: Bool
if #available(iOS 14.0, *) {
isMovieType = mediaType == UTType.movie.identifier
} else {
isMovieType = mediaType == "public.movie"
}
if isMovieType, let videoURL = info[.mediaURL] as? URL {
saveCapturedAsset(videoURL: videoURL)
}
}
}The Backward-compat: this only touches the registered-callback path; with no callback set, behavior stays exactly as before (save to library, single dismiss), so please keep that fully gated. Since #383 is already merged, could you open this as a new PR against |
When didCaptureMediaURL is set on TLPhotosPickerViewController, captured
photos and videos are written to a temp file and the URL is passed to the
callback instead of being saved to the photo library. Existing behaviour
is unchanged when the callback is not set.