This commit is contained in:
Isaac 2026-04-07 20:32:42 +02:00
commit 80d7198230
3 changed files with 153 additions and 35 deletions

View file

@ -754,7 +754,7 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode {
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Gallery_SaveImage, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in
f(.default)
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: media)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let strongSelf = self else {

View file

@ -3857,10 +3857,14 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
if let (message, _, _) = strongSelf.contentInfo(), let image = message.media.first(where: { $0 is TelegramMediaImage }) as? TelegramMediaImage, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil {
let context = strongSelf.context
var videoReference: AnyMediaReference?
if let video = image.video {
videoReference = .message(message: MessageReference(message), media: video)
}
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_SaveImage, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { [weak self] _, f in
f(.default)
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image))
let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference)
|> deliverOnMainQueue).start(completed: { [weak self] in
guard let strongSelf = self else {
return
@ -3868,7 +3872,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
guard let controller = strongSelf.galleryController() else {
return
}
controller.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .mediaSaved(text: strongSelf.presentationData.strings.Gallery_ImageSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
controller.present(UndoOverlayController(presentationData: strongSelf.presentationData, content: .mediaSaved(text: strongSelf.presentationData.strings.Gallery_ImageSaved), elevatedLayout: true, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
})
})))
}

View file

@ -89,50 +89,111 @@ public func fetchMediaData(context: AccountContext, postbox: Postbox, userLocati
}
}
public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference) -> Signal<Float, NoError> {
return fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
|> mapToSignal { state, isImage -> Signal<Float, NoError> in
switch state {
public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal<Float, NoError> {
let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
let videoData: Signal<FetchMediaDataState?, NoError>
if let video {
videoData = fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video)
|> map { state, _ in
return state
}
|> map(Optional.init)
} else {
videoData = .single(nil)
}
return combineLatest(
queue: Queue.mainQueue(),
mediaData,
videoData
)
|> mapToSignal { stateAndIsImage, videoStateAndIsImage -> Signal<Float, NoError> in
let isImage = stateAndIsImage.1
var mainData: MediaResourceData?
var videoData: MediaResourceData?
var waitForVideo = false
if let videoState = videoStateAndIsImage {
switch videoState {
case let .progress(value):
return .single(value * 0.95)
case let .data(data):
videoData = data
}
switch stateAndIsImage.0 {
case let .progress(value):
return .single(0.95 + 0.05 * value)
case let .data(data):
mainData = data
}
waitForVideo = true
} else {
switch stateAndIsImage.0 {
case let .progress(value):
return .single(value)
case let .data(data):
if data.complete {
return Signal<Float, NoError> { subscriber in
DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in
context.sharedContext.presentGlobalController(c, a)
}, openSettings: context.sharedContext.applicationBindings.openSettings, { authorized in
if !authorized {
subscriber.putCompletion()
return
}
let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4"
mainData = data
}
}
if let mainData, mainData.complete, videoData != nil || !waitForVideo {
return Signal<Float, NoError> { subscriber in
DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in
context.sharedContext.presentGlobalController(c, a)
}, openSettings: context.sharedContext.applicationBindings.openSettings, { authorized in
if !authorized {
subscriber.putCompletion()
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 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({
if isImage {
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path)) {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: fileData, options: nil)
}
} else {
if let _ = try? FileManager.default.copyItem(atPath: data.path, toPath: tempVideoPath) {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath))
}
}
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: jpegWithID, options: nil)
request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil)
}, completionHandler: { _, error in
if let error = error {
print("\(error)")
}
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
subscriber.putNext(1.0)
subscriber.putCompletion()
})
})
return ActionDisposable {
}
} else {
PHPhotoLibrary.shared().performChanges({
if isImage {
if let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: nil)
}
} else {
if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: tempVideoPath) {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath))
}
}
}, completionHandler: { _, error in
if let error {
print("\(error)")
}
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
subscriber.putNext(1.0)
subscriber.putCompletion()
})
}
} else {
return .complete()
})
return ActionDisposable {
}
}
} else {
return .complete()
}
}
}
@ -160,3 +221,56 @@ public func copyToPasteboard(context: AccountContext, postbox: Postbox, userLoca
}
|> mapToSignal { _ -> Signal<Void, NoError> in return .complete() }
}
private func addAssetIdentifierToJPEG(_ imageData: Data, assetIdentifier: String) -> Data? {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let uti = CGImageSourceGetType(source), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
return nil
}
let mutableData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(mutableData, uti, 1, nil) else {
return nil
}
var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
var maker = metadata[kCGImagePropertyMakerAppleDictionary as String] as? [String: Any] ?? [:]
maker["17"] = assetIdentifier
metadata[kCGImagePropertyMakerAppleDictionary as String] = maker
CGImageDestinationAddImage(destination, cgImage, metadata as CFDictionary)
CGImageDestinationFinalize(destination)
return mutableData as Data
}
private func addAssetIdentifierToVideo(inputURL: URL, outputURL: URL, assetIdentifier: String, completion: @escaping (Bool) -> Void) {
let asset = AVAsset(url: inputURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
completion(false)
return
}
let identifierItem = AVMutableMetadataItem()
identifierItem.keySpace = .quickTimeMetadata
identifierItem.key = AVMetadataKey.quickTimeMetadataKeyContentIdentifier as NSString
identifierItem.value = assetIdentifier as NSString
let stillImageTimeItem = AVMutableMetadataItem()
let keyStillImageTime = "com.apple.quicktime.still-image-time"
let keySpaceQuickTimeMetadata = "mdta"
stillImageTimeItem.key = keyStillImageTime as (NSCopying & NSObjectProtocol)?
stillImageTimeItem.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata)
stillImageTimeItem.value = 0 as (NSCopying & NSObjectProtocol)?
stillImageTimeItem.dataType = "com.apple.metadata.datatype.int8"
exportSession.outputURL = outputURL
exportSession.outputFileType = .mov
exportSession.metadata = [identifierItem, stillImageTimeItem]
exportSession.shouldOptimizeForNetworkUse = true
exportSession.exportAsynchronously {
completion(exportSession.status == .completed)
}
}