From 4ae15b42a710e9fcb7b21d9a96fa521c2712fb45 Mon Sep 17 00:00:00 2001 From: Isaac <> Date: Sun, 19 Apr 2026 23:45:34 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 17 +- ...-04-18-postbox-to-telegramengine-wave-3.md | 968 ++++++++++++++++++ ...postbox-to-telegramengine-wave-3-design.md | 237 +++++ .../Sources/BrowserInstantPageContent.swift | 4 +- .../Sources/Items/ChatImageGalleryItem.swift | 6 +- .../Items/UniversalVideoGalleryItem.swift | 6 +- .../Sources/InstantPageControllerNode.swift | 4 +- .../Sources/LegacyAttachmentMenu.swift | 8 +- .../Sources/LegacyAvatarPicker.swift | 8 +- submodules/SaveToCameraRoll/BUILD | 1 - .../Sources/SaveToCameraRoll.swift | 56 +- .../Sources/ShareController.swift | 7 +- .../Resources/TelegramEngineResources.swift | 33 + .../Sources/ChatQrCodeScreen.swift | 4 +- .../Sources/EditStories.swift | 4 +- .../Sources/MediaEditorScreen.swift | 1 - .../Sources/PeerInfoStoryGridScreen.swift | 2 +- .../StoryItemSetContainerComponent.swift | 2 +- .../ChatInterfaceStateContextMenus.swift | 2 +- .../TelegramUI/Sources/SaveMediaToFiles.swift | 4 +- 20 files changed, 1316 insertions(+), 58 deletions(-) create mode 100644 docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md create mode 100644 docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md diff --git a/CLAUDE.md b/CLAUDE.md index cc399a469e..422b158150 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,6 +107,21 @@ Before selecting a wave's module list, grep each candidate for: 1 task abandoned with recorded reason in the wave-2 plan: `SaveToCameraRoll` (full-module Postbox coupling, needs its own wave). +### Wave 3 outcome (2026-04-18) + +3 thin forwarders added on `TelegramEngine.Resources` over `MediaBox`: +- `fetch(reference:userLocation:userContentType:)` → `Signal` (Postbox return types remain a documented accepted leak) +- `status(resource: EngineMediaResource)` → `Signal` +- `data(resource: EngineMediaResource, pathExtension:, waitUntilFetchStatus:)` → `Signal` (takes a `Bool` rather than exposing `ResourceDataRequestOption`, per YAGNI) + +1 consumer submodule fully de-Postboxed: `SaveToCameraRoll`. Public signatures changed from `(context:, postbox: Postbox, userLocation:, …)` to `(context:, userLocation:, …)`; `FetchMediaDataState.data` payload changed from `MediaResourceData` to `EngineMediaResource.ResourceData`; internals rewired through `context.engine.resources.*`. 23 call sites across 14 files migrated atomically with the module. + +Pre-flight verified that `ShareController.swift:2406`'s `self.currentContext.stateManager.postbox` is equivalent to `context.account.postbox` in the `ShareControllerAppAccountContext` path (because `AccountStateManager` is constructed with the account's own `postbox`), so the `postbox:` argument could be dropped without behavior change. + +No tasks abandoned. Shape validated: "per-engine-facade-API migration + full consumer module rewrite" (the wave-2 shape, scaled up to a full module drop). + +Plan: `docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md` + ### Modules currently free of `import Postbox` (running tally) Consumer modules that no longer import Postbox, across all waves and standalone commits: @@ -119,6 +134,7 @@ Consumer modules that no longer import Postbox, across all waves and standalone - `PromptUI` (standalone cleanup) - `PresentationDataUtils` (standalone cleanup) - `MapResourceToAvatarSizes` (wave 2) +- `SaveToCameraRoll` (wave 3) ### Known future-wave candidates @@ -126,7 +142,6 @@ Surfaced by the wave-2 final review: - `TelegramEngine.Stickers.uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, …)` — same MediaResource migration as wave 2, plus `peer: Peer` which would naturally migrate to `EnginePeer` at the same time. Self-contained to a small number of call sites. - `submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift: public func uploadSecureIdFile(…, postbox: Postbox, …, resource: MediaResource)` — rule-2-sensitive (umbrella-type leak). Needs a paired wave with its caller(s). -- `SaveToCameraRoll` (wave-2 Task 8 abandonment) — three public functions take `postbox: Postbox`, plus internal `postbox.mediaBox.*` calls. Needs a full module-migration wave. - Classes conforming to `TelegramMediaResource` (need `isEqual(to: MediaResource)` override) remain **permanently blocked** from consumer-side migration: `ICloudFileResource`, `InstantPageExternalMediaResource`, `VideoLibraryMediaResource`, `YoutubeEmbedStoryboardMediaResource`. Either move the class into `TelegramCore` or keep `import Postbox` in its module. ### Build environment quirk diff --git a/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md b/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md new file mode 100644 index 0000000000..a1d8b9fbcf --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md @@ -0,0 +1,968 @@ +# Postbox → TelegramEngine Wave 3 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three thin forwarding methods on `TelegramEngine.Resources` for fetch/status/data, then migrate `SaveToCameraRoll` to use them, drop `import Postbox` from that module, and update all 23 call sites. + +**Architecture:** Two atomic commits on branch `refactor/postbox-to-engine-wave-3`. C1 adds the facades in isolation. C2 changes `SaveToCameraRoll`'s public API (drops the `postbox:` parameter, switches `FetchMediaDataState.data` payload from `MediaResourceData` to `EngineMediaResource.ResourceData`), rewrites the module's internals via `context.engine.resources.*`, removes `import Postbox`, and updates every caller in the same commit so the tree remains buildable. + +**Tech Stack:** Swift / Bazel. No unit tests exist in this repo — verification is a full project build. + +**Spec:** [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md) + +**Build command (use for every "full build" step):** + +```bash +source ~/.zshrc 2>/dev/null; PATH=/opt/homebrew/opt/ruby/bin:`gem environment gemdir`/bin:$PATH python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber 1 --configuration debug_sim_arm64 +``` + +The prefix `source ~/.zshrc 2>/dev/null;` is required because `TELEGRAM_CODESIGNING_GIT_PASSWORD` lives in `~/.zshrc` and the bash tool does not source shell config by default. + +--- + +## Task 1: Add `TelegramEngine.Resources.fetch/status/data` facades (C1) + +**Files:** +- Modify: [submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift:415-417](submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift#L415) + +- [ ] **Step 1: Insert the three facade methods** + +Open `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. Find the existing `applicationIcons()` method (currently the last method in the `Resources` class). Insert the three new methods immediately after it, still inside the `final class Resources` brace (before the closing `}`): + +```swift + public func applicationIcons() -> Signal { + return _internal_applicationIcons(account: account) + } + + public func fetch( + reference: MediaResourceReference, + userLocation: MediaResourceUserLocation, + userContentType: MediaResourceUserContentType + ) -> Signal { + return fetchedMediaResource( + mediaBox: self.account.postbox.mediaBox, + userLocation: userLocation, + userContentType: userContentType, + reference: reference + ) + } + + public func status( + resource: EngineMediaResource + ) -> Signal { + return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) + |> map { EngineMediaResource.FetchStatus($0) } + } + + public func data( + resource: EngineMediaResource, + pathExtension: String?, + waitUntilFetchStatus: Bool + ) -> Signal { + return self.account.postbox.mediaBox.resourceData( + resource._asResource(), + pathExtension: pathExtension, + option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) + ) + |> map { EngineMediaResource.ResourceData($0) } + } + } +} +``` + +- [ ] **Step 2: Full build — verify C1 compiles cleanly** + +Run the build command from the header. Expected: build succeeds with no errors. If a `signature mismatch` or `cannot find 'fetchedMediaResource'` error appears, double-check that `FetchedMediaResource.swift` and `MediaBox.swift` already export the referenced symbols (they do as of this plan's writing — no import changes are needed in `TelegramEngineResources.swift`, which already imports `Postbox`, `SwiftSignalKit`, and `TelegramApi`). + +- [ ] **Step 3: Commit C1** + +```bash +git add submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +git commit -m "$(cat <<'EOF' +TelegramEngine.Resources: add fetch/status/data facades + +Thin forwarders over MediaBox for the narrow surface SaveToCameraRoll +needs. Takes EngineMediaResource and returns EngineMediaResource-typed +results where applicable. Wave-3 groundwork. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task 2: Pre-flight — re-inventory call sites and verify ShareController postbox + +No code changes in this task. Its purpose is to catch drift from the spec's inventory before editing code, per CLAUDE.md's "inventory at execution time" guidance. + +**Files:** (read-only) +- Spec inventory: [docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md](docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md) +- Definition to verify: `submodules/ShareController/Sources/ShareController.swift` around line 2403 and `ShareControllerAppAccountContext` + +- [ ] **Step 1: Re-grep the current call-site set** + +Run: + +```bash +grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\(" submodules --include="*.swift" \ + | grep -v "SaveToCameraRoll/Sources/SaveToCameraRoll.swift" \ + | grep -v "private func saveToCameraRoll" \ + | grep -v "self\?\.saveToCameraRoll\|strongSelf\.saveToCameraRoll" +``` + +Expected output has exactly 23 lines across 14 files, matching the spec's inventory table: + +| Module | File | Expected count | +|---|---|---| +| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 | +| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 | +| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 | +| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 | +| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 | +| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 | +| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 | +| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 | +| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 | +| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 | +| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 | +| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 | +| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 | +| ShareController | `Sources/ShareController.swift` | 3 | + +If the count or file list has drifted meaningfully from this table, **stop**, report the drift, and request a spec revision before continuing. Additions of one or two call sites can be folded in; larger drift should pause the wave. + +- [ ] **Step 2: Verify `ShareController:2406` postbox equivalence** + +Read `submodules/ShareController/Sources/ShareController.swift` lines 2395–2420. The private helper `saveToCameraRoll(messages:completion:)` contains `let postbox = self.currentContext.stateManager.postbox` and passes it to `SaveToCameraRoll.saveToCameraRoll`. After the migration, `SaveToCameraRoll` will use `context.account.postbox.mediaBox` internally. + +The enclosing function gates on `self.currentContext as? ShareControllerAppAccountContext`. In that code path, `accountContext.context.account` is the `Account` that `ShareControllerAppAccountContext` was built from, and `self.currentContext.stateManager` is that same account's state manager. Therefore `accountContext.context.account.postbox === self.currentContext.stateManager.postbox`. + +Confirm this by reading the definition of `ShareControllerAppAccountContext` in `submodules/AccountContext/Sources/ShareController.swift` (or the file where it's defined — grep for `ShareControllerAppAccountContext` to locate). If the `stateManager` there is derived from the same `account` whose `postbox` is reachable via `context.account.postbox`, treat the two as equivalent and proceed. If they can diverge (e.g., share-extension account switching creates a separate state manager), **stop** and abandon the ShareController:2406 edit with a recorded reason before continuing — the rest of the wave still applies. + +- [ ] **Step 3: Record verification outcome** + +Write a one-line note in the executor's task log noting either "ShareController:2406 postbox equivalence confirmed" or "ShareController:2406 abandoned — reason: ...". No commit. + +--- + +## Task 3: Migrate `SaveToCameraRoll` module + +This task changes the module's public API and internals. Build will fail after this task because all callers are still passing `postbox:` — that's expected and will be fixed in Task 4, which must land in the same commit as this task. + +**Files:** +- Modify: [submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift](submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift) (entire file rewritten as shown below) + +- [ ] **Step 1: Rewrite `SaveToCameraRoll.swift`** + +Replace the file's contents with: + +```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 = 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 { + let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference) + let videoData: Signal + 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 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 { 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 { + return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference) + |> mapToSignal { state, isImage -> Signal in + if case let .data(data) = state, data.isComplete { + return Signal { 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 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) + } +} +``` + +The key differences from the original file: + +1. `import Postbox` — removed. +2. `FetchMediaDataState.data(MediaResourceData)` → `FetchMediaDataState.data(EngineMediaResource.ResourceData)`. +3. Three public functions drop their `postbox: Postbox` parameter. +4. `var resource: MediaResource?` → `var resource: TelegramMediaResource?`. +5. Inside `fetchMediaData`: build an `EngineMediaResource(resource)` once, and call `context.engine.resources.fetch / status / data` instead of `fetchedMediaResource(...)` / `postbox.mediaBox.resourceStatus(...)` / `postbox.mediaBox.resourceData(...)`. +6. `var mainData: MediaResourceData?` / `var videoData: MediaResourceData?` → `var ...: EngineMediaResource.ResourceData?`. +7. `mainData.complete` → `mainData.isComplete`. `data.complete` (in `copyToPasteboard`) → `data.isComplete`. Field `data.path` is unchanged. + +- [ ] **Step 2: Do not build yet — proceed to Task 4** + +Builds will fail until every caller in Task 4 is migrated. Do not run the build command here. No commit yet either — Task 3 and Task 4 share a single atomic commit in Task 5. + +--- + +## Task 4: Update all 23 call sites + +Every call site does one or both of two edits: + +- **Edit A (all 23 sites):** drop `postbox: someExpression,` from the argument list. +- **Edit B (the 7 sites that destructure `fetchMediaData`):** rename `.complete` → `.isComplete` on the destructured data value; `.path` stays the same. + +Each sub-step below is one file. No builds between files. No commit. Task 5 builds everything together. + +**Sub-task 4.1 — InstantPageUI** + +- [ ] **File:** [submodules/InstantPageUI/Sources/InstantPageControllerNode.swift](submodules/InstantPageUI/Sources/InstantPageControllerNode.swift) + +At line 1027, replace: + +```swift + let _ = copyToPasteboard(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +with: + +```swift + let _ = copyToPasteboard(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +At line 1032, replace: + +```swift + let _ = saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +with: + +```swift + let _ = saveToCameraRoll(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +**Sub-task 4.2 — LegacyMediaPickerUI / LegacyAttachmentMenu.swift** (destructures) + +- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift) + +At line 173, replace: + +```swift + let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) +``` + +with: + +```swift + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) +``` + +In the `.start` block that follows (around line 175), replace `data.complete` with `data.isComplete` (only the `.complete` boolean access — do not touch `data.path`). + +At line 490, replace: + +```swift + let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: editCurrentMedia) +``` + +with: + +```swift + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: editCurrentMedia) +``` + +In the destructuring block that follows (around line 492), replace `data.complete` with `data.isComplete`. + +**Sub-task 4.3 — LegacyMediaPickerUI / LegacyAvatarPicker.swift** (destructures) + +- [ ] **File:** [submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift](submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift) + +At line 58, replace: + +```swift + let imageSignal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: false) +``` + +with: + +```swift + let imageSignal = fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: false) +``` + +In the `|> map` block immediately after (line ~60), replace `data.complete` with `data.isComplete`. + +At line 67, replace: + +```swift + let videoSignal = isVideo ? fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: true) +``` + +with: + +```swift + let videoSignal = isVideo ? fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: true) +``` + +In the `|> map` block immediately after (line ~69), replace `data.complete` with `data.isComplete`. + +**Sub-task 4.4 — BrowserUI / BrowserInstantPageContent.swift** + +- [ ] **File:** [submodules/BrowserUI/Sources/BrowserInstantPageContent.swift](submodules/BrowserUI/Sources/BrowserInstantPageContent.swift) + +At line 1175, replace: + +```swift + let _ = copyToPasteboard(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +with: + +```swift + let _ = copyToPasteboard(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +At line 1180, replace: + +```swift + let _ = saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +with: + +```swift + let _ = saveToCameraRoll(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() +``` + +**Sub-task 4.5 — GalleryUI / ChatImageGalleryItem.swift** (one destructures) + +- [ ] **File:** [submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift](submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift) + +At line 732, replace: + +```swift + let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) +``` + +with: + +```swift + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) +``` + +In the `.start` block that follows (around line 734), replace `data.complete` with `data.isComplete`. + +At line 758, replace: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: media) +``` + +with: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: media) +``` + +**Sub-task 4.6 — GalleryUI / UniversalVideoGalleryItem.swift** + +- [ ] **File:** [submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift](submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift) + +At line 3764, replace: + +```swift + let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) +``` + +with: + +```swift + let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) +``` + +At line 3810, replace: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) +``` + +with: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) +``` + +At line 3867, replace: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference) +``` + +with: + +```swift + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: image), video: videoReference) +``` + +**Sub-task 4.7 — TelegramUI / MediaEditorScreen / MediaEditorScreen.swift** (destructures) + +- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift) + +At line 5136, in the multi-line call starting with `let _ = (fetchMediaData(`, delete the line ` postbox: self.context.account.postbox,`. The remaining call should read: + +```swift + let _ = (fetchMediaData( + context: self.context, + userLocation: .other, + mediaReference: file + ) |> deliverOnMainQueue).start(next: { [weak self] state, _ in +``` + +Inside this closure, the destructuring is `if case let .data(data) = state { let path = data.path ... }` — `data.path` stays unchanged, and this site does not access `data.complete` (verified against the current file). No Edit B rename needed here. + +**Sub-task 4.8 — TelegramUI / MediaEditorScreen / EditStories.swift** (destructures) + +- [ ] **File:** [submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift](submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift) + +At line 37, replace: + +```swift + return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) +``` + +with: + +```swift + return fetchMediaData(context: context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) +``` + +At line 39 (inside the `mapToSignal`), replace: + +```swift + guard case let .data(data) = value, data.complete else { +``` + +with: + +```swift + guard case let .data(data) = value, data.isComplete else { +``` + +(`data.path` accesses below this line remain unchanged.) + +**Sub-task 4.9 — TelegramUI / ChatQrCodeScreen / ChatQrCodeScreen.swift** (destructures) + +- [ ] **File:** [submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift](submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift) + +At line 2505, replace: + +```swift + let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) +``` + +with: + +```swift + let _ = (fetchMediaData(context: context, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) +``` + +At line 2507, replace: + +```swift + guard case let .data(data) = value, data.complete else { +``` + +with: + +```swift + guard case let .data(data) = value, data.isComplete else { +``` + +**Sub-task 4.10 — TelegramUI / StoryContainerScreen / StoryItemSetContainerComponent.swift** + +- [ ] **File:** [submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift](submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift) + +At line 5980, replace: + +```swift + let disposable = (saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) +``` + +with: + +```swift + let disposable = (saveToCameraRoll(context: component.context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) +``` + +**Sub-task 4.11 — TelegramUI / PeerInfoStoryGridScreen / PeerInfoStoryGridScreen.swift** + +- [ ] **File:** [submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift](submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift) + +At line 268, replace: + +```swift + signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) +``` + +with: + +```swift + signals.append(saveToCameraRoll(context: component.context, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) +``` + +**Sub-task 4.12 — TelegramUI / Sources / ChatInterfaceStateContextMenus.swift** + +- [ ] **File:** [submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift](submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift) + +At line 1419, replace: + +```swift + let _ = (saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) +``` + +with: + +```swift + let _ = (saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) +``` + +**Sub-task 4.13 — TelegramUI / Sources / SaveMediaToFiles.swift** (destructures) + +- [ ] **File:** [submodules/TelegramUI/Sources/SaveMediaToFiles.swift](submodules/TelegramUI/Sources/SaveMediaToFiles.swift) + +At line 27, replace: + +```swift + var signal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: fileReference.abstract) +``` + +with: + +```swift + var signal = fetchMediaData(context: context, userLocation: .other, mediaReference: fileReference.abstract) +``` + +At line 63, replace: + +```swift + if data.complete { +``` + +with: + +```swift + if data.isComplete { +``` + +(`data.path` accesses in the block below remain unchanged.) + +**Sub-task 4.14 — ShareController / ShareController.swift** + +- [ ] **File:** [submodules/ShareController/Sources/ShareController.swift](submodules/ShareController/Sources/ShareController.swift) + +At line 2406, after verifying Task 2's postbox-equivalence, replace: + +```swift + return SaveToCameraRoll.saveToCameraRoll(context: context, postbox: postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) +``` + +with: + +```swift + return SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) +``` + +Also delete the now-unused local binding above (line 2403): + +```swift + let postbox = self.currentContext.stateManager.postbox +``` + +(This line is used only by the `saveToCameraRoll` call on line 2406. If the build later flags it as unused instead of an error, leave it; but preferred is to remove the dead binding.) + +At line 2432, replace: + +```swift + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) +``` + +with: + +```swift + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) +``` + +At line 2441, replace: + +```swift + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) +``` + +with: + +```swift + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) +``` + +(The abandonment branch: if Task 2's verification found `stateManager.postbox` and `account.postbox` are non-equivalent, skip the `line 2406` edit, leave `let postbox = self.currentContext.stateManager.postbox` in place, and revert Task 3's change to the `saveToCameraRoll` public signature only for this one callsite — which is impossible without duplicate signatures, so in that case abandon the entire wave and record the reason in a new commit to the plan.) + +--- + +## Task 5: Full build and commit C2 + +- [ ] **Step 1: Run the full project build** + +Run the build command from the header. Expected: build succeeds with no errors across all modules. + +If there are failures, they fall into a few predictable categories and are fixed in place — do not split into another commit: + +- **"cannot convert value of type 'Postbox' to expected argument type"** — a call site was missed. Grep again for `postbox: ` usages in the migrated files and fix. +- **"value of type 'EngineMediaResource.ResourceData' has no member 'complete'"** — an Edit B site was missed. Rename to `isComplete`. +- **"use of unresolved identifier 'fetchedMediaResource'" or similar inside `SaveToCameraRoll.swift`** — indicates `import Postbox` was dropped but a bare Postbox top-level function is still referenced. Replace the call with the engine facade introduced in Task 1. +- **Warnings about unused local `let postbox = ...`** — delete the binding. + +Re-run the build after each fix until it succeeds. + +- [ ] **Step 2: Stage all touched files** + +```bash +git add \ + submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift \ + submodules/InstantPageUI/Sources/InstantPageControllerNode.swift \ + submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift \ + submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift \ + submodules/BrowserUI/Sources/BrowserInstantPageContent.swift \ + submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift \ + submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift \ + submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift \ + submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift \ + submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift \ + submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift \ + submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift \ + submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift \ + submodules/TelegramUI/Sources/SaveMediaToFiles.swift \ + submodules/ShareController/Sources/ShareController.swift +``` + +- [ ] **Step 3: Verify the diff is clean** + +Run: + +```bash +git diff --staged --stat +``` + +Expected: exactly 15 files changed, with SaveToCameraRoll.swift having the largest diff (the full-file rewrite) and each call-site file showing small line-count changes. + +- [ ] **Step 4: Commit C2** + +```bash +git commit -m "$(cat <<'EOF' +SaveToCameraRoll: drop import Postbox via engine.resources facades + +Migrates SaveToCameraRoll's three public functions to take context +only (no more postbox:), switches the FetchMediaDataState.data payload +from MediaResourceData to EngineMediaResource.ResourceData, rewrites +internals via TelegramEngine.Resources.fetch/status/data, and drops +import Postbox from the module. All 23 call sites across 14 files +updated in the same commit to keep the tree buildable. + +Wave-3 of the Postbox -> TelegramEngine refactor. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +- [ ] **Step 5: Verify branch log** + +Run: + +```bash +git log --oneline refactor/postbox-to-engine-wave-3 | head -5 +``` + +Expected: the top two commits on the branch are `SaveToCameraRoll: drop import Postbox ...` (C2) and `TelegramEngine.Resources: add fetch/status/data facades` (C1), above the previous spec commits. + +- [ ] **Step 6: Update CLAUDE.md tally** + +Open `CLAUDE.md`, find the "Modules currently free of `import Postbox`" section, and add `SaveToCameraRoll (wave 3)` to the bullet list. Also add a "Wave 3 outcome (2026-04-18)" subsection documenting: three facades added on `TelegramEngine.Resources`, `SaveToCameraRoll` fully de-Postboxed, 23 call sites migrated. If any call site was abandoned in Task 2, record the reason here. + +Commit: + +```bash +git add CLAUDE.md +git commit -m "$(cat <<'EOF' +CLAUDE.md: record wave-3 outcome + +Adds SaveToCameraRoll to the Postbox-free module tally and documents +the three new TelegramEngine.Resources facades added in wave 3. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Success criteria + +- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift` contains no `import Postbox`. +- `grep -rnE "(fetchMediaData|saveToCameraRoll|copyToPasteboard)\\(" submodules --include="*.swift" | grep "postbox:"` returns zero matches outside of the private `collectExternalShareResource`/`collectExternalShareItems` helpers in `ShareController.swift` (which take their own `postbox:` parameters unrelated to SaveToCameraRoll). +- Full build succeeds in `debug_sim_arm64` configuration. +- Three branch commits above the spec commits: C1 (facades), C2 (SaveToCameraRoll + callers), C3 (CLAUDE.md tally). diff --git a/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md new file mode 100644 index 0000000000..2109717f29 --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-postbox-to-telegramengine-wave-3-design.md @@ -0,0 +1,237 @@ +# Postbox → TelegramEngine refactor, Wave 3: MediaBox fetch/status/data facades + SaveToCameraRoll + +**Date:** 2026-04-18 +**Status:** Design approved; awaiting implementation plan. +**Predecessors:** Waves 1 and 2 (`docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md`, `docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md`). + +## Goal + +1. Unblock the full-module de-Postboxing of `submodules/SaveToCameraRoll` (abandoned in Wave 2) by adding engine-side facades for the `mediaBox` methods it uses. +2. Migrate `SaveToCameraRoll`'s three public functions to use those facades, drop `import Postbox` from the module, and update all call sites. + +This wave follows the validated Wave-2 shape ("per-API migration, modify in place, update all call sites in one commit"), not the Wave-1 shape ("per-module Postbox drop"). + +## Non-goals + +- Migrating any caller file (`InstantPageUI`, `BrowserUI`, `GalleryUI`, `ShareController`, `TelegramUI`, etc.) to drop its `import Postbox`. Each imports Postbox for many unrelated reasons; this wave only changes how they invoke `SaveToCameraRoll`. +- Adding facades for other `mediaBox` methods beyond the three SaveToCameraRoll needs (`cachedResourceRepresentation`, `completedResourcePath`, `storeResourceData`, etc.). Additive work belongs in future waves when a consumer needs them. +- Wrapping `FetchResourceSourceType` / `FetchResourceError` — these remain Postbox types, exposed by the `fetch` facade as a documented accepted leak. SaveToCameraRoll does not inspect these values. +- Adding `.incremental(waitUntilFetchStatus:)` to the `data` facade, or any of `range` / `statsCategory` / `reportResultStatus` / `preferBackgroundReferenceRevalidation` / `continueInBackground` to the `fetch` facade. + +## Scope and inventory + +### New engine surface + +Three thin forwarding methods added to `TelegramEngine.Resources` in `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. No new wrapper structs or classes. + +```swift +public extension TelegramEngine { + final class Resources { + // ...existing methods... + + public func fetch( + reference: MediaResourceReference, + userLocation: MediaResourceUserLocation, + userContentType: MediaResourceUserContentType + ) -> Signal { + return fetchedMediaResource( + mediaBox: self.account.postbox.mediaBox, + userLocation: userLocation, + userContentType: userContentType, + reference: reference + ) + } + + public func status( + resource: EngineMediaResource + ) -> Signal { + return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) + |> map { EngineMediaResource.FetchStatus($0) } + } + + public func data( + resource: EngineMediaResource, + pathExtension: String?, + waitUntilFetchStatus: Bool + ) -> Signal { + return self.account.postbox.mediaBox.resourceData( + resource._asResource(), + pathExtension: pathExtension, + option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) + ) + |> map { EngineMediaResource.ResourceData($0) } + } + } +} +``` + +Design choices: + +- **`data` takes a `waitUntilFetchStatus: Bool`**, not Postbox's `ResourceDataRequestOption` enum. SaveToCameraRoll only ever uses `.complete(waitUntilFetchStatus:)`. If a future consumer needs `.incremental(...)`, extend the facade at that point. +- **`fetch` takes only the 4 parameters SaveToCameraRoll uses.** `range`, `statsCategory`, `reportResultStatus`, `preferBackgroundReferenceRevalidation`, `continueInBackground` can be added additively when a consumer requires them. +- **`reference:` keeps the `MediaResourceReference` Postbox type.** Callers construct it inline via `mediaReference.resourceReference(resource)` and pass it without a local binding; no `import Postbox` is induced at the call site. +- **No wrapping of `FetchResourceSourceType` / `FetchResourceError`.** SaveToCameraRoll calls `.start()` on the `fetch` signal without inspecting the value; it does not import Postbox merely to use these types. Recorded here as an accepted leak. + +### SaveToCameraRoll public API changes + +The enum payload and three public function signatures change. Every caller breaks until updated. + +Before: + +```swift +public enum FetchMediaDataState { + case progress(Float) + case data(MediaResourceData) +} + +public func fetchMediaData( + context: AccountContext, postbox: Postbox, + userLocation: MediaResourceUserLocation, + customUserContentType: MediaResourceUserContentType? = nil, + mediaReference: AnyMediaReference, forceVideo: Bool = false +) -> Signal<(FetchMediaDataState, Bool), NoError> + +public func saveToCameraRoll( + context: AccountContext, postbox: Postbox, + userLocation: MediaResourceUserLocation, + customUserContentType: MediaResourceUserContentType? = nil, + mediaReference: AnyMediaReference, video: AnyMediaReference? = nil +) -> Signal + +public func copyToPasteboard( + context: AccountContext, postbox: Postbox, + userLocation: MediaResourceUserLocation, + mediaReference: AnyMediaReference +) -> Signal +``` + +After: + +```swift +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> + +public func saveToCameraRoll( + context: AccountContext, + userLocation: MediaResourceUserLocation, + customUserContentType: MediaResourceUserContentType? = nil, + mediaReference: AnyMediaReference, video: AnyMediaReference? = nil +) -> Signal + +public func copyToPasteboard( + context: AccountContext, + userLocation: MediaResourceUserLocation, + mediaReference: AnyMediaReference +) -> Signal +``` + +### SaveToCameraRoll internal changes + +- `var resource: MediaResource?` → `var resource: TelegramMediaResource?` (TelegramCore protocol; matches CLAUDE.md cheat-sheet guidance). `representation.resource` and `file.resource` already return `TelegramMediaResource`, so no wrapping is needed at assignment. +- `fetchedMediaResource(mediaBox: postbox.mediaBox, …)` → `context.engine.resources.fetch(reference: mediaReference.resourceReference(resource), userLocation: userLocation, userContentType: userContentType)`. +- `postbox.mediaBox.resourceStatus(resource)` → `context.engine.resources.status(resource: EngineMediaResource(resource))`. The `switch status { case .Local … }` body is unchanged because `EngineMediaResource.FetchStatus` has the same cases (`.Local`, `.Remote(progress:)`, `.Fetching(isActive:progress:)`, `.Paused(progress:)`). +- `postbox.mediaBox.resourceData(resource, pathExtension: fileExtension, option: .complete(waitUntilFetchStatus: true))` → `context.engine.resources.data(resource: EngineMediaResource(resource), pathExtension: fileExtension, waitUntilFetchStatus: true)`. +- Local `MediaResourceData` bindings (`mainData: MediaResourceData?`, `videoData: MediaResourceData?`) and `case let .data(data):` destructurings → use `EngineMediaResource.ResourceData`. +- Field renames inside SaveToCameraRoll: `data.complete` → `data.isComplete`. `data.path` unchanged. `data.size` is not used internally. +- `import Postbox` removed from the file. + +### Call-site migration (23 sites, 14 files) + +Two mechanical edits per site. + +Edit A — drop the `postbox:` argument: + +```swift +// Before +saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: …, mediaReference: …) +// After +saveToCameraRoll(context: context, userLocation: …, mediaReference: …) +``` + +Edit B — update `FetchMediaDataState.data` field accesses at the ~7 sites that destructure `fetchMediaData` results: + +- `data.complete` → `data.isComplete` +- `data.path` → unchanged +- `data.size` → `data.availableSize` (if used; likely not) + +Inventory (captured 2026-04-18): + +| Module | File | Calls | +|---|---|---| +| InstantPageUI | `Sources/InstantPageControllerNode.swift` | 2 | +| LegacyMediaPickerUI | `Sources/LegacyAttachmentMenu.swift` | 2 (destructures) | +| LegacyMediaPickerUI | `Sources/LegacyAvatarPicker.swift` | 2 (destructures) | +| BrowserUI | `Sources/BrowserInstantPageContent.swift` | 2 | +| GalleryUI | `Sources/Items/ChatImageGalleryItem.swift` | 2 (one destructures) | +| GalleryUI | `Sources/Items/UniversalVideoGalleryItem.swift` | 3 | +| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/MediaEditorScreen.swift` | 1 (destructures) | +| TelegramUI (MediaEditorScreen) | `Components/MediaEditorScreen/Sources/EditStories.swift` | 1 (destructures) | +| TelegramUI (ChatQrCodeScreen) | `Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift` | 1 (destructures) | +| TelegramUI (StoryContainer) | `Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift` | 1 | +| TelegramUI (PeerInfoStoryGrid) | `Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift` | 1 | +| TelegramUI | `Sources/ChatInterfaceStateContextMenus.swift` | 1 | +| TelegramUI | `Sources/SaveMediaToFiles.swift` | 1 (destructures) | +| ShareController | `Sources/ShareController.swift` | 3 | + +**Execution-time re-inventory:** before editing any code, the executor must re-grep for `fetchMediaData|saveToCameraRoll|copyToPasteboard` call sites across `submodules/`. If the count or file list drifts meaningfully from this table, abandon editing and revise the plan. + +### Postbox-drop tally update + +- `SaveToCameraRoll` joins the tally of modules free of `import Postbox`. +- No caller file is expected to drop `import Postbox` in this wave. + +## Commit plan + +Two commits, landing in order on `refactor/postbox-to-engine-wave-3`. + +### C1 — `TelegramEngine.Resources: add fetch/status/data facades` + +- Touches only `submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift`. +- Adds the three methods from the "New engine surface" section above. No behavior changes; no consumer changes. +- Buildable in isolation. + +### C2 — `SaveToCameraRoll: drop import Postbox via engine.resources facades` + +Atomic; must land as one commit because signature changes break every unmigrated caller. + +- `submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift`: public signature changes, `FetchMediaDataState.data` payload switch, internal rewrites, `import Postbox` removal. +- All 23 call sites in the inventory table updated in the same commit. +- ~7 destructuring sites also get the `data.complete` → `data.isComplete` rename. + +## Build verification + +Per CLAUDE.md, the only verification available is a full project build. No unit tests exist in the repo. + +- After C1: full build. +- After C2: full build. + +Both builds use the standard command from `CLAUDE.md` (Telegram build recipe with `--configuration debug_sim_arm64`), prefixed with `source ~/.zshrc 2>/dev/null;` to pick up `TELEGRAM_CODESIGNING_GIT_PASSWORD`. + +## Risks and mitigations + +- **New call site appears between planning and execution.** Mitigation: re-grep at execution time before editing; abandon & revise if count drifts meaningfully. +- **`FetchResourceSourceType` / `FetchResourceError` are Postbox types.** Mitigation: SaveToCameraRoll never inspects these; future consumers that need to pattern-match will wrap these types in a later wave. +- **A consumer turns out to need a mediaBox facade not in this spec** (e.g., `cachedResourceRepresentation`). Mitigation: out of scope. Abandon that caller's migration; the facade commit still stands on its own. +- **`context.engine` unavailable at some call site.** Risk minimal: `AccountContext.engine` is a protocol requirement in `submodules/AccountContext/Sources/AccountContext.swift`, so it is universally available at any site that already has `context: AccountContext`. All 23 sites match. +- **ShareController:2406 uses a non-`context.account.postbox` Postbox.** At `submodules/ShareController/Sources/ShareController.swift:2406`, the call reads `let postbox = self.currentContext.stateManager.postbox` and passes that as `postbox:`. After migration, SaveToCameraRoll internally uses `context.account.postbox.mediaBox` via the engine. In the gated `ShareControllerAppAccountContext` path, `accountContext.context.account.stateManager` should match `self.currentContext.stateManager`, so the two postboxes are equivalent; verify this at execution time before editing. If they can diverge (e.g., during share-extension account switching), this specific call site must be abandoned with a recorded reason — the rest of the wave is unaffected. +- **Umbrella-type rule-2 compliance.** No `Postbox` / `Account` / `MediaBox` typealias is added. No new wrapper struct is introduced. ✅ + +## Abandonment criteria + +If any call site cannot be migrated mechanically — for example, it passes a non-`context.account.postbox` custom `Postbox`, or constructs a `MediaResourceReference` in a way that forces a retained `import Postbox` in a file the wave intends to de-Postbox — abandon that specific call site with a recorded reason in the plan. The facade commit (C1) still stands on its own; SaveToCameraRoll's internal migration still lands if at least the other callers migrate. If too many call sites abandon, abandon the whole wave and record lessons. + +## Expected outcome + +- `TelegramEngine.Resources` has three new thin forwarders. +- `SaveToCameraRoll` no longer imports Postbox. +- Running tally of Postbox-free consumer modules: Wave 1 cohort + `StickerPeekUI`, `PromptUI`, `PresentationDataUtils` (standalone) + `MapResourceToAvatarSizes` (Wave 2) + **`SaveToCameraRoll` (Wave 3)**. +- Zero behavior change. diff --git a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift index 52ff3cecfc..ec69d0ec82 100644 --- a/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift +++ b/submodules/BrowserUI/Sources/BrowserInstantPageContent.swift @@ -1172,12 +1172,12 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuCopy), action: { [weak self] in if let self, let image = media.media._asMedia() as? TelegramMediaImage { let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: []) - let _ = copyToPasteboard(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() + let _ = copyToPasteboard(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() } }), ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_LinkDialogSave, accessibilityLabel: self.presentationData.strings.Conversation_LinkDialogSave), action: { [weak self] in if let self, let image = media.media._asMedia() as? TelegramMediaImage { let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: []) - let _ = saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() + let _ = saveToCameraRoll(context: self.context, userLocation: self.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() } }), ContextMenuAction(content: .text(title: self.presentationData.strings.Conversation_ContextMenuShare, accessibilityLabel: self.presentationData.strings.Conversation_ContextMenuShare), action: { [weak self] in if let self, let (webPage, _) = self.webPage, let image = media.media._asMedia() as? TelegramMediaImage { diff --git a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift index fc38976031..6c166bfe15 100644 --- a/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/ChatImageGalleryItem.swift @@ -729,9 +729,9 @@ final class ChatImageGalleryItemNode: ZoomableContentGalleryItemNode { guard let self else { return } - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) |> deliverOnMainQueue).start(next: { [weak self] (value, isImage) in - guard let self, case let .data(data) = value, data.complete, isImage, let image = UIImage(contentsOfFile: data.path) else { + guard let self, case let .data(data) = value, data.isComplete, isImage, let image = UIImage(contentsOfFile: data.path) else { return } let sendSticker = self.sendSticker @@ -755,7 +755,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) + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: media) |> deliverOnMainQueue).start(completed: { [weak self] in guard let strongSelf = self else { return diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index cc7163e468..5419f6f58f 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -3761,7 +3761,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { let stringSaved = self.presentationData.strings.Story_TooltipSaved let saveFileReference: AnyMediaReference = qualityFile.abstract - let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) + let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference) let disposable = (saveSignal |> deliverOnMainQueue).start(next: { [weak saveScreen] progress in @@ -3807,7 +3807,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { switch self.fetchStatus { case .Local: - let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) + let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file)) |> deliverOnMainQueue).start(completed: { [weak self] in guard let self else { return @@ -3864,7 +3864,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { 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), video: videoReference) + let _ = (SaveToCameraRoll.saveToCameraRoll(context: context, 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 diff --git a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift index 9e55514eeb..2f0445f0b7 100644 --- a/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift +++ b/submodules/InstantPageUI/Sources/InstantPageControllerNode.swift @@ -1024,12 +1024,12 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate { let controller = makeContextMenuController(actions: [ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuCopy, accessibilityLabel: self.strings.Conversation_ContextMenuCopy), action: { [weak self] in if let strongSelf = self, case let .image(image) = media.media { let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: []) - let _ = copyToPasteboard(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() + let _ = copyToPasteboard(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() } }), ContextMenuAction(content: .text(title: self.strings.Conversation_LinkDialogSave, accessibilityLabel: self.strings.Conversation_LinkDialogSave), action: { [weak self] in if let strongSelf = self, case let .image(image) = media.media { let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: nil, partialReference: nil, flags: []) - let _ = saveToCameraRoll(context: strongSelf.context, postbox: strongSelf.context.account.postbox, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() + let _ = saveToCameraRoll(context: strongSelf.context, userLocation: strongSelf.sourceLocation.userLocation, mediaReference: .standalone(media: media)).start() } }), ContextMenuAction(content: .text(title: self.strings.Conversation_ContextMenuShare, accessibilityLabel: self.strings.Conversation_ContextMenuShare), action: { [weak self] in if let strongSelf = self, let (webPage, _) = strongSelf.webPage, case let .image(image) = media.media { diff --git a/submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift b/submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift index acc28dacf4..8c0c0ec5e0 100644 --- a/submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift +++ b/submodules/LegacyMediaPickerUI/Sources/LegacyAttachmentMenu.swift @@ -170,9 +170,9 @@ public func legacyMediaEditor( sendMessagesWithSignals: @escaping ([Any]?, Bool, Int32, Bool) -> Void, present: @escaping (ViewController, Any?) -> Void ) { - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media) + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: media) |> deliverOnMainQueue).start(next: { (value, isImage) in - guard case let .data(data) = value, data.complete else { + guard case let .data(data) = value, data.isComplete else { return } @@ -487,9 +487,9 @@ public func legacyAttachmentMenu( let editCurrentItem = TGMenuSheetButtonItemView(title: title, type: TGMenuSheetButtonTypeDefault, fontSize: fontSize, action: { [weak controller] in controller?.dismiss(animated: true) - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: editCurrentMedia) + let _ = (fetchMediaData(context: context, userLocation: .other, mediaReference: editCurrentMedia) |> deliverOnMainQueue).start(next: { (value, isImage) in - guard case let .data(data) = value, data.complete else { + guard case let .data(data) = value, data.isComplete else { return } diff --git a/submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift b/submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift index d367e03a9d..c5099d246c 100644 --- a/submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift +++ b/submodules/LegacyMediaPickerUI/Sources/LegacyAvatarPicker.swift @@ -55,18 +55,18 @@ public func presentLegacyAvatarPicker(holder: Atomic, signup: Bool, t public func legacyAvatarEditor(context: AccountContext, media: AnyMediaReference, transitionView: UIView?, senderName: String? = nil, present: @escaping (ViewController, Any?) -> Void, imageCompletion: @escaping (UIImage) -> Void, videoCompletion: @escaping (UIImage, URL, TGVideoEditAdjustments) -> Void) { let isVideo = !((media.media as? TelegramMediaImage)?.videoRepresentations.isEmpty ?? true) - let imageSignal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: false) + let imageSignal = fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: false) |> map { (value, _) -> (UIImage?, Bool) in - if case let .data(data) = value, data.complete { + if case let .data(data) = value, data.isComplete { return (UIImage(contentsOfFile: data.path), true) } else { return (nil, false) } } - let videoSignal = isVideo ? fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: media, forceVideo: true) + let videoSignal = isVideo ? fetchMediaData(context: context, userLocation: .other, mediaReference: media, forceVideo: true) |> map { (value, isImage) -> (URL?, Bool) in - if case let .data(data) = value, data.complete && !isImage { + if case let .data(data) = value, data.isComplete && !isImage { return (URL(fileURLWithPath: data.path), true) } else { return (nil, false) diff --git a/submodules/SaveToCameraRoll/BUILD b/submodules/SaveToCameraRoll/BUILD index d7db544f62..f5410229c0 100644 --- a/submodules/SaveToCameraRoll/BUILD +++ b/submodules/SaveToCameraRoll/BUILD @@ -11,7 +11,6 @@ swift_library( ], deps = [ "//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit", - "//submodules/Postbox:Postbox", "//submodules/TelegramCore:TelegramCore", "//submodules/Display:Display", "//submodules/AccountContext:AccountContext", diff --git a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift index eaa06ce4d7..2f8b30b155 100644 --- a/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift +++ b/submodules/SaveToCameraRoll/Sources/SaveToCameraRoll.swift @@ -1,7 +1,6 @@ import Foundation import UIKit import SwiftSignalKit -import Postbox import TelegramCore import Photos import Display @@ -12,11 +11,11 @@ import LegacyComponents public enum FetchMediaDataState { case progress(Float) - case data(MediaResourceData) + case data(EngineMediaResource.ResourceData) } -public func fetchMediaData(context: AccountContext, postbox: Postbox, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, forceVideo: Bool = false) -> Signal<(FetchMediaDataState, Bool), NoError> { - var resource: MediaResource? +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 @@ -53,11 +52,16 @@ public func fetchMediaData(context: AccountContext, postbox: Postbox, userLocati if let customUserContentType { userContentType = customUserContentType } - + if let resource = resource { + let engineResource = EngineMediaResource(resource) let fetchedData: Signal = Signal { subscriber in - let fetched = fetchedMediaResource(mediaBox: postbox.mediaBox, userLocation: userLocation, userContentType: userContentType, reference: mediaReference.resourceReference(resource)).start() - let status = postbox.mediaBox.resourceStatus(resource).start(next: { status 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)) @@ -69,7 +73,11 @@ public func fetchMediaData(context: AccountContext, postbox: Postbox, userLocati subscriber.putNext(.progress(progress)) } }) - let data = postbox.mediaBox.resourceData(resource, pathExtension: fileExtension, option: .complete(waitUntilFetchStatus: true)).start(next: { next in + let data = context.engine.resources.data( + resource: engineResource, + pathExtension: fileExtension, + waitUntilFetchStatus: true + ).start(next: { next in subscriber.putNext(.data(next)) }, completed: { subscriber.putCompletion() @@ -89,11 +97,11 @@ public func fetchMediaData(context: AccountContext, postbox: Postbox, userLocati } } -public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal { - let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference) +public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal { + let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference) let videoData: Signal if let video { - videoData = fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video) + videoData = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video) |> map { state, _ in return state } @@ -101,7 +109,7 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca } else { videoData = .single(nil) } - + return combineLatest( queue: Queue.mainQueue(), mediaData, @@ -109,8 +117,8 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca ) |> mapToSignal { stateAndIsImage, videoStateAndIsImage -> Signal in let isImage = stateAndIsImage.1 - var mainData: MediaResourceData? - var videoData: MediaResourceData? + var mainData: EngineMediaResource.ResourceData? + var videoData: EngineMediaResource.ResourceData? var waitForVideo = false if let videoState = videoStateAndIsImage { switch videoState { @@ -134,7 +142,7 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca mainData = data } } - if let mainData, mainData.complete, videoData != nil || !waitForVideo { + if let mainData, mainData.isComplete, videoData != nil || !waitForVideo { return Signal { subscriber in DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in context.sharedContext.presentGlobalController(c, a) @@ -143,16 +151,16 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca 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 } @@ -188,7 +196,7 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca }) } }) - + return ActionDisposable { } } @@ -198,13 +206,13 @@ public func saveToCameraRoll(context: AccountContext, postbox: Postbox, userLoca } } -public func copyToPasteboard(context: AccountContext, postbox: Postbox, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal { - return fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, mediaReference: mediaReference) +public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal { + return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference) |> mapToSignal { state, isImage -> Signal in - if case let .data(data) = state, data.complete { + if case let .data(data) = state, data.isComplete { return Signal { 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) @@ -212,7 +220,7 @@ public func copyToPasteboard(context: AccountContext, postbox: Postbox, userLoca } subscriber.putNext(Void()) subscriber.putCompletion() - + return EmptyDisposable } } else { diff --git a/submodules/ShareController/Sources/ShareController.swift b/submodules/ShareController/Sources/ShareController.swift index 76d9fff2c7..fb82dbc312 100644 --- a/submodules/ShareController/Sources/ShareController.swift +++ b/submodules/ShareController/Sources/ShareController.swift @@ -2400,10 +2400,9 @@ public final class ShareController: ViewController { } let context = accountContext.context - let postbox = self.currentContext.stateManager.postbox let signals: [Signal] = messages.compactMap { message -> Signal? in if let media = message.media.first { - return SaveToCameraRoll.saveToCameraRoll(context: context, postbox: postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) + return SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: media)) } else { return nil } @@ -2429,7 +2428,7 @@ public final class ShareController: ViewController { let context = accountContext.context let media = TelegramMediaImage(imageId: MediaId(namespace: 0, id: 0), representations: representations.map({ $0.representation }), immediateThumbnailData: nil, reference: nil, partialReference: nil, flags: []) - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: .standalone(media: media)) |> map(Optional.init), dismissImmediately: true, completion: {}) } private func saveToCameraRoll(mediaReference: AnyMediaReference, completion: (() -> Void)?) { @@ -2438,7 +2437,7 @@ public final class ShareController: ViewController { } let context = accountContext.context - self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) + self.controllerNode.transitionToProgressWithValue(signal: SaveToCameraRoll.saveToCameraRoll(context: context, userLocation: .other, mediaReference: mediaReference) |> map(Optional.init), dismissImmediately: completion == nil, completion: completion ?? {}) } private func switchToAccount(account: ShareControllerAccountContext, animateIn: Bool) { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift index d0758efd41..442002ffd0 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Resources/TelegramEngineResources.swift @@ -415,5 +415,38 @@ public extension TelegramEngine { public func applicationIcons() -> Signal { return _internal_applicationIcons(account: account) } + + public func fetch( + reference: MediaResourceReference, + userLocation: MediaResourceUserLocation, + userContentType: MediaResourceUserContentType + ) -> Signal { + return fetchedMediaResource( + mediaBox: self.account.postbox.mediaBox, + userLocation: userLocation, + userContentType: userContentType, + reference: reference + ) + } + + public func status( + resource: EngineMediaResource + ) -> Signal { + return self.account.postbox.mediaBox.resourceStatus(resource._asResource()) + |> map { EngineMediaResource.FetchStatus($0) } + } + + public func data( + resource: EngineMediaResource, + pathExtension: String?, + waitUntilFetchStatus: Bool + ) -> Signal { + return self.account.postbox.mediaBox.resourceData( + resource._asResource(), + pathExtension: pathExtension, + option: .complete(waitUntilFetchStatus: waitUntilFetchStatus) + ) + |> map { EngineMediaResource.ResourceData($0) } + } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 3f0412ae82..f990990658 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -2502,9 +2502,9 @@ private enum RenderVideoResult { } private func renderVideo(context: AccountContext, backgroundImage: UIImage, userLocation: MediaResourceUserLocation, media: TelegramMediaFile, videoFrame: CGRect, completion: @escaping (URL?) -> Void) { - let _ = (fetchMediaData(context: context, postbox: context.account.postbox, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) + let _ = (fetchMediaData(context: context, userLocation: userLocation, mediaReference: AnyMediaReference.standalone(media: media)) |> deliverOnMainQueue).startStandalone(next: { value, isImage in - guard case let .data(data) = value, data.complete else { + guard case let .data(data) = value, data.isComplete else { return } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift index e8cc73e516..24a1b9a5af 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift @@ -34,9 +34,9 @@ public extension MediaEditorScreenImpl { return .single(.draft(source, Int64(storyItem.id))) } else { let media = storyItem.media._asMedia() - return fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) + return fetchMediaData(context: context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: storyItem.id, media: media)) |> mapToSignal { (value, isImage) -> Signal in - guard case let .data(data) = value, data.complete else { + guard case let .data(data) = value, data.isComplete else { return .complete() } if let image = UIImage(contentsOfFile: data.path) { diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index f9f3e01ef5..698c304792 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -5135,7 +5135,6 @@ public final class MediaEditorScreenImpl: ViewController, MediaEditorScreen, UID } let _ = (fetchMediaData( context: self.context, - postbox: self.context.account.postbox, userLocation: .other, mediaReference: file ) |> deliverOnMainQueue).start(next: { [weak self] state, _ in diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift index 3b3a3c0082..f8b8971285 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoStoryGridScreen/Sources/PeerInfoStoryGridScreen.swift @@ -265,7 +265,7 @@ final class PeerInfoStoryGridScreenComponent: Component { for (_, item) in sortedItems { let itemOffset = progressStart progressStart += valueNorm - signals.append(saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) + signals.append(saveToCameraRoll(context: component.context, userLocation: .other, mediaReference: .story(peer: peerReference, id: item.id, media: item.media._asMedia())) |> map { progress -> Float in return itemOffset + progress * valueNorm }) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index fa1efdfe9e..0c0a5b5f27 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -5977,7 +5977,7 @@ public final class StoryItemSetContainerComponent: Component { let stringSaving = component.strings.Story_TooltipSaving let stringSaved = component.strings.Story_TooltipSaved - let disposable = (saveToCameraRoll(context: component.context, postbox: component.context.account.postbox, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) + let disposable = (saveToCameraRoll(context: component.context, userLocation: .peer(peerReference.id), customUserContentType: .story, mediaReference: .story(peer: peerReference, id: component.slice.item.storyItem.id, media: component.slice.item.storyItem.media._asMedia())) |> deliverOnMainQueue).start(next: { [weak saveScreen] progress in guard let saveScreen else { return diff --git a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift index 14aa237b33..d38804e068 100644 --- a/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift +++ b/submodules/TelegramUI/Sources/ChatInterfaceStateContextMenus.swift @@ -1416,7 +1416,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState actions.append(.action(ContextMenuActionItem(text: isVideo ? chatPresentationInterfaceState.strings.Gallery_SaveVideo : chatPresentationInterfaceState.strings.Gallery_SaveImage, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Save"), color: theme.actionSheet.primaryTextColor) }, action: { _, f in - let _ = (saveToCameraRoll(context: context, postbox: context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) + let _ = (saveToCameraRoll(context: context, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) |> deliverOnMainQueue).startStandalone(completed: { Queue.mainQueue().after(0.2) { let presentationData = context.sharedContext.currentPresentationData.with { $0 } diff --git a/submodules/TelegramUI/Sources/SaveMediaToFiles.swift b/submodules/TelegramUI/Sources/SaveMediaToFiles.swift index 7ad5919923..af8c4a35b4 100644 --- a/submodules/TelegramUI/Sources/SaveMediaToFiles.swift +++ b/submodules/TelegramUI/Sources/SaveMediaToFiles.swift @@ -24,7 +24,7 @@ func saveMediaToFiles(context: AccountContext, fileReference: FileMediaReference } } - var signal = fetchMediaData(context: context, postbox: context.account.postbox, userLocation: .other, mediaReference: fileReference.abstract) + var signal = fetchMediaData(context: context, userLocation: .other, mediaReference: fileReference.abstract) var cancelImpl: (() -> Void)? let presentationData = context.sharedContext.currentPresentationData.with { $0 } @@ -60,7 +60,7 @@ func saveMediaToFiles(context: AccountContext, fileReference: FileMediaReference case .progress: break case let .data(data): - if data.complete { + if data.isComplete { var symlinkPath = data.path + ".mp3" if fileSize(symlinkPath) != nil { try? FileManager.default.removeItem(atPath: symlinkPath)