Postbox -> TelegramEngine wave 3: MediaBox fetch/status/data facades + SaveToCameraRoll

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Isaac 2026-04-19 23:45:34 +02:00
parent 791bb62148
commit 4ae15b42a7
20 changed files with 1316 additions and 58 deletions

View file

@ -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<FetchResourceSourceType, FetchResourceError>` (Postbox return types remain a documented accepted leak)
- `status(resource: EngineMediaResource)``Signal<EngineMediaResource.FetchStatus, NoError>`
- `data(resource: EngineMediaResource, pathExtension:, waitUntilFetchStatus:)``Signal<EngineMediaResource.ResourceData, NoError>` (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

View file

@ -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<TelegramApplicationIcons, NoError> {
return _internal_applicationIcons(account: account)
}
public func fetch(
reference: MediaResourceReference,
userLocation: MediaResourceUserLocation,
userContentType: MediaResourceUserContentType
) -> Signal<FetchResourceSourceType, FetchResourceError> {
return fetchedMediaResource(
mediaBox: self.account.postbox.mediaBox,
userLocation: userLocation,
userContentType: userContentType,
reference: reference
)
}
public func status(
resource: EngineMediaResource
) -> Signal<EngineMediaResource.FetchStatus, NoError> {
return self.account.postbox.mediaBox.resourceStatus(resource._asResource())
|> map { EngineMediaResource.FetchStatus($0) }
}
public func data(
resource: EngineMediaResource,
pathExtension: String?,
waitUntilFetchStatus: Bool
) -> Signal<EngineMediaResource.ResourceData, NoError> {
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) <noreply@anthropic.com>
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 23952420. 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<FetchMediaDataState, NoError> = Signal { subscriber in
let fetched = context.engine.resources.fetch(
reference: mediaReference.resourceReference(resource),
userLocation: userLocation,
userContentType: userContentType
).start()
let status = context.engine.resources.status(resource: engineResource).start(next: { status in
switch status {
case .Local:
subscriber.putNext(.progress(1.0))
case .Remote:
subscriber.putNext(.progress(0.0))
case let .Fetching(_, progress):
subscriber.putNext(.progress(progress))
case let .Paused(progress):
subscriber.putNext(.progress(progress))
}
})
let data = context.engine.resources.data(
resource: engineResource,
pathExtension: fileExtension,
waitUntilFetchStatus: true
).start(next: { next in
subscriber.putNext(.data(next))
}, completed: {
subscriber.putCompletion()
})
return ActionDisposable {
fetched.dispose()
status.dispose()
data.dispose()
}
}
return fetchedData
|> map { data in
return (data, isImage)
}
} else {
return .complete()
}
}
public func saveToCameraRoll(context: AccountContext, userLocation: MediaResourceUserLocation, customUserContentType: MediaResourceUserContentType? = nil, mediaReference: AnyMediaReference, video: AnyMediaReference? = nil) -> Signal<Float, NoError> {
let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
let videoData: Signal<FetchMediaDataState?, NoError>
if let video {
videoData = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: video)
|> map { state, _ in
return state
}
|> map(Optional.init)
} else {
videoData = .single(nil)
}
return combineLatest(
queue: Queue.mainQueue(),
mediaData,
videoData
)
|> mapToSignal { stateAndIsImage, videoStateAndIsImage -> Signal<Float, NoError> in
let isImage = stateAndIsImage.1
var mainData: EngineMediaResource.ResourceData?
var videoData: EngineMediaResource.ResourceData?
var waitForVideo = false
if let videoState = videoStateAndIsImage {
switch videoState {
case let .progress(value):
return .single(value * 0.95)
case let .data(data):
videoData = data
}
switch stateAndIsImage.0 {
case let .progress(value):
return .single(0.95 + 0.05 * value)
case let .data(data):
mainData = data
}
waitForVideo = true
} else {
switch stateAndIsImage.0 {
case let .progress(value):
return .single(value)
case let .data(data):
mainData = data
}
}
if let mainData, mainData.isComplete, videoData != nil || !waitForVideo {
return Signal<Float, NoError> { subscriber in
DeviceAccess.authorizeAccess(to: .mediaLibrary(.save), presentationData: context.sharedContext.currentPresentationData.with { $0 }, present: { c, a in
context.sharedContext.presentGlobalController(c, a)
}, openSettings: context.sharedContext.applicationBindings.openSettings, { authorized in
if !authorized {
subscriber.putCompletion()
return
}
let tempVideoPath = NSTemporaryDirectory() + "\(Int64.random(in: Int64.min ... Int64.max)).mp4"
if isImage, let videoData, let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) {
let id = UUID().uuidString
let jpegWithID = addAssetIdentifierToJPEG(imageData, assetIdentifier: id)!
let outputVideoURL = URL(fileURLWithPath: NSTemporaryDirectory() + "\(id).mov")
try? FileManager.default.copyItem(atPath: videoData.path, toPath: tempVideoPath)
addAssetIdentifierToVideo(inputURL: URL(fileURLWithPath: tempVideoPath), outputURL: outputVideoURL, assetIdentifier: id) { success in
guard success else { return }
PHPhotoLibrary.shared().performChanges({
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: jpegWithID, options: nil)
request.addResource(with: .pairedVideo, fileURL: outputVideoURL, options: nil)
}, completionHandler: { _, error in
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
subscriber.putNext(1.0)
subscriber.putCompletion()
})
}
} else {
PHPhotoLibrary.shared().performChanges({
if isImage {
if let imageData = try? Data(contentsOf: URL(fileURLWithPath: mainData.path)) {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: imageData, options: nil)
}
} else {
if let _ = try? FileManager.default.copyItem(atPath: mainData.path, toPath: tempVideoPath) {
PHAssetChangeRequest.creationRequestForAssetFromVideo(atFileURL: URL(fileURLWithPath: tempVideoPath))
}
}
}, completionHandler: { _, error in
if let error {
print("\(error)")
}
let _ = try? FileManager.default.removeItem(atPath: tempVideoPath)
subscriber.putNext(1.0)
subscriber.putCompletion()
})
}
})
return ActionDisposable {
}
}
} else {
return .complete()
}
}
}
public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal<Void, NoError> {
return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference)
|> mapToSignal { state, isImage -> Signal<Void, NoError> in
if case let .data(data) = state, data.isComplete {
return Signal<Void, NoError> { subscriber in
let pasteboard = UIPasteboard.general
if mediaReference.media is TelegramMediaImage {
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: .mappedIfSafe) {
pasteboard.setData(fileData, forPasteboardType: kUTTypeJPEG as String)
}
}
subscriber.putNext(Void())
subscriber.putCompletion()
return EmptyDisposable
}
} else {
return .complete()
}
}
|> mapToSignal { _ -> Signal<Void, NoError> in return .complete() }
}
private func addAssetIdentifierToJPEG(_ imageData: Data, assetIdentifier: String) -> Data? {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil), let uti = CGImageSourceGetType(source), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
return nil
}
let mutableData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(mutableData, uti, 1, nil) else {
return nil
}
var metadata = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [String: Any] ?? [:]
var maker = metadata[kCGImagePropertyMakerAppleDictionary as String] as? [String: Any] ?? [:]
maker["17"] = assetIdentifier
metadata[kCGImagePropertyMakerAppleDictionary as String] = maker
CGImageDestinationAddImage(destination, cgImage, metadata as CFDictionary)
CGImageDestinationFinalize(destination)
return mutableData as Data
}
private func addAssetIdentifierToVideo(inputURL: URL, outputURL: URL, assetIdentifier: String, completion: @escaping (Bool) -> Void) {
let asset = AVAsset(url: inputURL)
guard let exportSession = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
completion(false)
return
}
let identifierItem = AVMutableMetadataItem()
identifierItem.keySpace = .quickTimeMetadata
identifierItem.key = AVMetadataKey.quickTimeMetadataKeyContentIdentifier as NSString
identifierItem.value = assetIdentifier as NSString
let stillImageTimeItem = AVMutableMetadataItem()
let keyStillImageTime = "com.apple.quicktime.still-image-time"
let keySpaceQuickTimeMetadata = "mdta"
stillImageTimeItem.key = keyStillImageTime as (NSCopying & NSObjectProtocol)?
stillImageTimeItem.keySpace = AVMetadataKeySpace(rawValue: keySpaceQuickTimeMetadata)
stillImageTimeItem.value = 0 as (NSCopying & NSObjectProtocol)?
stillImageTimeItem.dataType = "com.apple.metadata.datatype.int8"
exportSession.outputURL = outputURL
exportSession.outputFileType = .mov
exportSession.metadata = [identifierItem, stillImageTimeItem]
exportSession.shouldOptimizeForNetworkUse = true
exportSession.exportAsynchronously {
completion(exportSession.status == .completed)
}
}
```
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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).

View file

@ -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<FetchResourceSourceType, FetchResourceError> {
return fetchedMediaResource(
mediaBox: self.account.postbox.mediaBox,
userLocation: userLocation,
userContentType: userContentType,
reference: reference
)
}
public func status(
resource: EngineMediaResource
) -> Signal<EngineMediaResource.FetchStatus, NoError> {
return self.account.postbox.mediaBox.resourceStatus(resource._asResource())
|> map { EngineMediaResource.FetchStatus($0) }
}
public func data(
resource: EngineMediaResource,
pathExtension: String?,
waitUntilFetchStatus: Bool
) -> Signal<EngineMediaResource.ResourceData, NoError> {
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<Float, NoError>
public func copyToPasteboard(
context: AccountContext, postbox: Postbox,
userLocation: MediaResourceUserLocation,
mediaReference: AnyMediaReference
) -> Signal<Void, NoError>
```
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<Float, NoError>
public func copyToPasteboard(
context: AccountContext,
userLocation: MediaResourceUserLocation,
mediaReference: AnyMediaReference
) -> Signal<Void, NoError>
```
### 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.

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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
}

View file

@ -55,18 +55,18 @@ public func presentLegacyAvatarPicker(holder: Atomic<NSObject?>, 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)

View file

@ -11,7 +11,6 @@ swift_library(
],
deps = [
"//submodules/SSignalKit/SwiftSignalKit:SwiftSignalKit",
"//submodules/Postbox:Postbox",
"//submodules/TelegramCore:TelegramCore",
"//submodules/Display:Display",
"//submodules/AccountContext:AccountContext",

View file

@ -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<FetchMediaDataState, NoError> = 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<Float, NoError> {
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<Float, NoError> {
let mediaData: Signal<(FetchMediaDataState, Bool), NoError> = fetchMediaData(context: context, userLocation: userLocation, customUserContentType: customUserContentType, mediaReference: mediaReference)
let videoData: Signal<FetchMediaDataState?, NoError>
if let video {
videoData = fetchMediaData(context: context, 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<Float, NoError> 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<Float, NoError> { 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<Void, NoError> {
return fetchMediaData(context: context, postbox: postbox, userLocation: userLocation, mediaReference: mediaReference)
public func copyToPasteboard(context: AccountContext, userLocation: MediaResourceUserLocation, mediaReference: AnyMediaReference) -> Signal<Void, NoError> {
return fetchMediaData(context: context, userLocation: userLocation, mediaReference: mediaReference)
|> mapToSignal { state, isImage -> Signal<Void, NoError> in
if case let .data(data) = state, data.complete {
if case let .data(data) = state, data.isComplete {
return Signal<Void, NoError> { subscriber in
let pasteboard = UIPasteboard.general
if mediaReference.media is TelegramMediaImage {
if let fileData = try? Data(contentsOf: URL(fileURLWithPath: data.path), options: .mappedIfSafe) {
pasteboard.setData(fileData, forPasteboardType: kUTTypeJPEG as String)
@ -212,7 +220,7 @@ public func copyToPasteboard(context: AccountContext, postbox: Postbox, userLoca
}
subscriber.putNext(Void())
subscriber.putCompletion()
return EmptyDisposable
}
} else {

View file

@ -2400,10 +2400,9 @@ public final class ShareController: ViewController {
}
let context = accountContext.context
let postbox = self.currentContext.stateManager.postbox
let signals: [Signal<Float, NoError>] = messages.compactMap { message -> Signal<Float, NoError>? 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) {

View file

@ -415,5 +415,38 @@ public extension TelegramEngine {
public func applicationIcons() -> Signal<TelegramApplicationIcons, NoError> {
return _internal_applicationIcons(account: account)
}
public func fetch(
reference: MediaResourceReference,
userLocation: MediaResourceUserLocation,
userContentType: MediaResourceUserContentType
) -> Signal<FetchResourceSourceType, FetchResourceError> {
return fetchedMediaResource(
mediaBox: self.account.postbox.mediaBox,
userLocation: userLocation,
userContentType: userContentType,
reference: reference
)
}
public func status(
resource: EngineMediaResource
) -> Signal<EngineMediaResource.FetchStatus, NoError> {
return self.account.postbox.mediaBox.resourceStatus(resource._asResource())
|> map { EngineMediaResource.FetchStatus($0) }
}
public func data(
resource: EngineMediaResource,
pathExtension: String?,
waitUntilFetchStatus: Bool
) -> Signal<EngineMediaResource.ResourceData, NoError> {
return self.account.postbox.mediaBox.resourceData(
resource._asResource(),
pathExtension: pathExtension,
option: .complete(waitUntilFetchStatus: waitUntilFetchStatus)
)
|> map { EngineMediaResource.ResourceData($0) }
}
}
}

View file

@ -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
}

View file

@ -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<MediaEditorScreenImpl.Subject?, NoError> 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) {

View file

@ -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

View file

@ -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
})

View file

@ -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

View file

@ -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 }

View file

@ -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)