Files
Isaac 4ae15b42a7 Postbox -> TelegramEngine wave 3: MediaBox fetch/status/data facades + SaveToCameraRoll
Adds three thin forwarding methods on TelegramEngine.Resources
(fetch, status, data) over MediaBox, then migrates SaveToCameraRoll's
three public functions to use them, drops import Postbox from the
module (source + Bazel dep), and updates all 23 call sites across 14
caller files atomically.

Bundled: spec + fix + plan + C1 facades + C2 SaveToCameraRoll rewrite
+ BUILD dep drop + CLAUDE.md outcome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 23:45:34 +02:00

285 lines
12 KiB
Swift

import Foundation
import UIKit
import SwiftSignalKit
import TelegramCore
import Photos
import Display
import MobileCoreServices
import DeviceAccess
import AccountContext
import LegacyComponents
public enum FetchMediaDataState {
case progress(Float)
case data(EngineMediaResource.ResourceData)
}
public func fetchMediaData(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, forceVideo: Bool = false) -> Signal<(FetchMediaDataState, Bool), NoError> {
var resource: TelegramMediaResource?
var isImage = true
var fileExtension: String?
var userContentType: MediaResourceUserContentType = .other
if let image = mediaReference.media as? TelegramMediaImage {
userContentType = .image
if let video = image.videoRepresentations.last, forceVideo {
resource = video.resource
isImage = false
} else if let representation = largestImageRepresentation(image.representations) {
resource = representation.resource
}
} else if let file = mediaReference.media as? TelegramMediaFile {
userContentType = MediaResourceUserContentType(file: file)
resource = file.resource
if file.isVideo || file.mimeType.hasPrefix("video/") {
isImage = false
}
let maybeExtension = ((file.fileName ?? "") as NSString).pathExtension
if !maybeExtension.isEmpty {
fileExtension = maybeExtension
}
} else if let webpage = mediaReference.media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
if let file = content.file {
resource = file.resource
if file.isVideo {
isImage = false
}
} else if let image = content.image {
if let representation = largestImageRepresentation(image.representations) {
resource = representation.resource
}
}
}
if let customUserContentType {
userContentType = customUserContentType
}
if let resource = resource {
let engineResource = EngineMediaResource(resource)
let fetchedData: Signal<FetchMediaDataState, NoError> = Signal { subscriber in
let fetched = context.engine.resources.fetch(
reference: mediaReference.resourceReference(resource),
userLocation: userLocation,
userContentType: userContentType
).start()
let status = context.engine.resources.status(resource: engineResource).start(next: { status in
switch status {
case .Local:
subscriber.putNext(.progress(1.0))
case .Remote:
subscriber.putNext(.progress(0.0))
case let .Fetching(_, progress):
subscriber.putNext(.progress(progress))
case let .Paused(progress):
subscriber.putNext(.progress(progress))
}
})
let data = context.engine.resources.data(
resource: engineResource,
pathExtension: fileExtension,
waitUntilFetchStatus: true
).start(next: { next in
subscriber.putNext(.data(next))
}, completed: {
subscriber.putCompletion()
})
return ActionDisposable {
fetched.dispose()
status.dispose()
data.dispose()
}
}
return fetchedData
|> map { data in
return (data, isImage)
}
} else {
return .complete()
}
}
public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal<Float, NoError> {
let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
let videoData: Signal<FetchMediaDataState?, NoError>
if let video {
videoData = fetchMediaData(context: context, 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: EngineMediaResource.ResourceData?
var videoData: EngineMediaResource.ResourceData?
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):
mainData = data
}
}
if let mainData, mainData.isComplete, 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({
let request = PHAssetCreationRequest.forAsset()
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 {
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()
})
}
})
return ActionDisposable {
}
}
} else {
return .complete()
}
}
}
public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal<Void, NoError> {
return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference)
|> mapToSignal { state, isImage -> Signal<Void, NoError> in
if case let .data(data) = state, data.isComplete {
return Signal<Void, NoError> { subscriber in
let pasteboard = UIPasteboard.general
if mediaReference.media is TelegramMediaImage {
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: .mappedIfSafe) {
pasteboard.setData(fileData, forPasteboardType: kUTTypeJPEG as String)
}
}
subscriber.putNext(Void())
subscriber.putCompletion()
return EmptyDisposable
}
} else {
return .complete()
}
}
|> 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)
}
}