Skip to content

Commit e035d7e

Browse files
AlexV525claude
andcommitted
Return the local proxy when offline for Optimize-Storage assets
On-device log for #1118 revealed a second gap: with the network off, both the PHImageManager (HighQualityFormat + networkAccessAllowed=YES) primary path AND the PHAssetResource walker fail with `NSURLErrorNotConnectedToInternet (-1009)`, because both are trying to materialise the iCloud original. The user's caller then receives an opaque -1009 with no image bytes — worse UX than Photos.app itself, which happily displays the local downsampled proxy under Optimize Storage. `AssetEntity.originFile` should degrade gracefully the same way, not fail hard. Add a tail fallback `fetchOfflineImageProxyFor:` that fires after both the primary and walker terminal-fail. It issues one more PHImageManager request with `deliveryMode=Opportunistic + networkAccessAllowed=NO`, accepts the degraded result, and writes to a `proxy_`-prefixed filename in the standard image cache directory. Callers get *something* displayable offline; hitting the primary path online later still materialises the iCloud original because `cachedPathForAsset:` does not scan the `proxy_` prefix — no cross-mode pollution. Also add an error log line inside the primary `dataHandler` so field diagnostics can distinguish "PHImageManager errored with -1009" from "PHImageManager returned no data" — the on-device log that surfaced this offline gap was silent on which of the two triggered the walker fallback. Wired into both terminal-failure branches of `runWalker` — the common case (PHImageManager primary → walker fallback → both fail) and the RAW/exotic case (walker primary → PHImageManager fallback → both fail). Guard against double-reply from Opportunistic mode's multi-callback contract with a `__block BOOL replied` — with networkAccessAllowed=NO a second callback is unlikely, but PMResultHandler treats a second reply as a programmer error. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 283e9cc commit e035d7e

1 file changed

Lines changed: 140 additions & 5 deletions

File tree

  • darwin/photo_manager/Sources/photo_manager/core

darwin/photo_manager/Sources/photo_manager/core/PMManager.m

Lines changed: 140 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1479,11 +1479,26 @@ - (void)fetchOriginImageFile:(PHAsset *)asset resultHandler:(PMResultHandler *)h
14791479
}
14801480
__strong typeof(weakSelf) inner = weakSelf;
14811481
if (!inner) { return; }
1482+
void (^tryOfflineProxy)(NSObject *finalError) = ^(NSObject *finalError) {
1483+
// Every network-touching path has failed. Try one last request
1484+
// with networkAccessAllowed=NO to surface the local proxy
1485+
// Photos keeps under Optimize Storage — better a small image
1486+
// than an opaque -1009.
1487+
[inner fetchOfflineImageProxyFor:asset
1488+
progressHandler:progressHandler
1489+
block:^(NSString *proxyPath, NSObject *proxyError) {
1490+
if (proxyPath) {
1491+
[handler reply:proxyPath];
1492+
return;
1493+
}
1494+
[inner notifyProgress:progressHandler progress:0 state:PMProgressStateFailed];
1495+
[handler replyError:finalError ?: proxyError];
1496+
}];
1497+
};
14821498
if (seedError) {
14831499
// PHImageManager already ran and failed — walker was the
1484-
// fallback. Nothing else to try.
1485-
[inner notifyProgress:progressHandler progress:0 state:PMProgressStateFailed];
1486-
[handler replyError:walkerError ?: seedError];
1500+
// fallback. Try offline proxy as last resort.
1501+
tryOfflineProxy(walkerError ?: seedError);
14871502
return;
14881503
}
14891504
// Walker ran as the primary path (RAW / exotic UTI) and every
@@ -1497,8 +1512,7 @@ - (void)fetchOriginImageFile:(PHAsset *)asset resultHandler:(PMResultHandler *)h
14971512
[handler reply:fbPath];
14981513
return;
14991514
}
1500-
[inner notifyProgress:progressHandler progress:0 state:PMProgressStateFailed];
1501-
[handler replyError:walkerError ?: fbError];
1515+
tryOfflineProxy(walkerError ?: fbError);
15021516
}];
15031517
}];
15041518
};
@@ -1677,6 +1691,8 @@ - (void)fallbackFetchImageDataFor:(PHAsset *)asset
16771691
}
16781692
NSObject *error = info[PHImageErrorKey];
16791693
if (error) {
1694+
[[PMLogUtils sharedInstance] info:[NSString stringWithFormat:
1695+
@"[#1118] PHImageManager error: %@", error]];
16801696
block(nil, error);
16811697
return;
16821698
}
@@ -1740,6 +1756,125 @@ - (void)fallbackFetchImageDataFor:(PHAsset *)asset
17401756
});
17411757
}
17421758

1759+
// Tail fallback used when both the PHImageManager (HighQualityFormat +
1760+
// networkAccessAllowed=YES) primary and the PHAssetResource walker have
1761+
// failed — typically because the device is offline and the requested
1762+
// resource is only present as an iCloud stub. Requests one more time with
1763+
// deliveryMode=Opportunistic + networkAccessAllowed=NO and *accepts the
1764+
// degraded result*, so the caller receives the local downsampled proxy
1765+
// (the "small image" Photos.app itself falls back to offline) instead of
1766+
// an opaque network error. The proxy is cached at a distinct
1767+
// `proxy_`-prefixed filename so a subsequent online call still traverses
1768+
// the primary path and materialises the iCloud original — no cross-mode
1769+
// pollution.
1770+
- (void)fetchOfflineImageProxyFor:(PHAsset *)asset
1771+
progressHandler:(NSObject <PMProgressHandlerProtocol> *)progressHandler
1772+
block:(void (^)(NSString *path, NSObject *error))block {
1773+
NSFileManager *manager = NSFileManager.defaultManager;
1774+
NSString *base = [self makeAssetOutputPath:asset resource:nil isOrigin:YES fileType:nil manager:manager];
1775+
NSString *dir = [base stringByDeletingLastPathComponent];
1776+
NSString *proxyPath = [dir stringByAppendingPathComponent:
1777+
[@"proxy_" stringByAppendingString:[base lastPathComponent]]];
1778+
if ([manager fileExistsAtPath:proxyPath]) {
1779+
[[PMLogUtils sharedInstance] info:[NSString stringWithFormat:
1780+
@"[#1118] local proxy cache hit → %@", proxyPath]];
1781+
[self notifySuccess:progressHandler];
1782+
block(proxyPath, nil);
1783+
return;
1784+
}
1785+
1786+
PHImageRequestOptions *options = [PHImageRequestOptions new];
1787+
[options setDeliveryMode:PHImageRequestOptionsDeliveryModeOpportunistic];
1788+
[options setNetworkAccessAllowed:NO];
1789+
[options setSynchronous:NO];
1790+
[options setVersion:PHImageRequestOptionsVersionCurrent];
1791+
1792+
__weak typeof(self) weakSelf = self;
1793+
// Opportunistic mode can fire the result handler more than once in
1794+
// principle. With networkAccessAllowed=NO a second callback is unlikely
1795+
// (no iCloud round trip is scheduled), but guard against double-reply
1796+
// regardless — the wrapping PMResultHandler treats a second reply as a
1797+
// programmer error.
1798+
__block BOOL replied = NO;
1799+
void (^dataHandler)(NSData *_Nullable, NSDictionary *_Nullable) = ^(NSData *_Nullable imageData, NSDictionary *_Nullable info) {
1800+
__strong typeof(weakSelf) strongSelf = weakSelf;
1801+
if (!strongSelf) { return; }
1802+
if (replied) { return; }
1803+
if ([info[PHImageCancelledKey] boolValue]) {
1804+
replied = YES;
1805+
[[PMLogUtils sharedInstance] info:@"[#1118] local-proxy PHImageManager cancelled"];
1806+
block(nil, [NSError errorWithDomain:@"PMPhotoManager" code:-2 userInfo:@{
1807+
NSLocalizedDescriptionKey: @"PHImageManager request was cancelled."
1808+
}]);
1809+
return;
1810+
}
1811+
NSObject *error = info[PHImageErrorKey];
1812+
if (error) {
1813+
replied = YES;
1814+
[[PMLogUtils sharedInstance] info:[NSString stringWithFormat:
1815+
@"[#1118] local-proxy PHImageManager error: %@", error]];
1816+
block(nil, error);
1817+
return;
1818+
}
1819+
if (!imageData) {
1820+
replied = YES;
1821+
[[PMLogUtils sharedInstance] info:@"[#1118] local-proxy no data available"];
1822+
block(nil, [NSError errorWithDomain:@"PMPhotoManager" code:-1 userInfo:@{
1823+
NSLocalizedDescriptionKey: @"No local proxy available for asset."
1824+
}]);
1825+
return;
1826+
}
1827+
replied = YES;
1828+
NSUInteger dataLength = imageData.length;
1829+
BOOL degraded = [info[PHImageResultIsDegradedKey] boolValue];
1830+
dispatch_async(strongSelf->_imageFileProcessingQueue, ^{
1831+
__strong typeof(weakSelf) innerSelf = weakSelf;
1832+
if (!innerSelf) { return; }
1833+
NSError *writeError;
1834+
[imageData writeToFile:proxyPath options:NSDataWritingAtomic error:&writeError];
1835+
if (writeError) {
1836+
block(nil, writeError);
1837+
return;
1838+
}
1839+
[[PMLogUtils sharedInstance] info:[NSString stringWithFormat:
1840+
@"[#1118] local proxy delivered %lu bytes%@%@",
1841+
(unsigned long)dataLength, degraded ? @" (degraded)" : @"", proxyPath]];
1842+
[innerSelf notifySuccess:progressHandler];
1843+
block(proxyPath, nil);
1844+
});
1845+
};
1846+
1847+
dispatch_async(dispatch_get_main_queue(), ^{
1848+
__strong typeof(weakSelf) strongSelf = weakSelf;
1849+
if (!strongSelf) { return; }
1850+
if (@available(iOS 13.0, macOS 10.15, *)) {
1851+
[strongSelf.cachingManager requestImageDataAndOrientationForAsset:asset
1852+
options:options
1853+
resultHandler:^(NSData *_Nullable imageData,
1854+
NSString *_Nullable dataUTI,
1855+
CGImagePropertyOrientation orientation,
1856+
NSDictionary *_Nullable info) {
1857+
dataHandler(imageData, info);
1858+
}];
1859+
} else {
1860+
#if TARGET_OS_IOS
1861+
[strongSelf.cachingManager requestImageDataForAsset:asset
1862+
options:options
1863+
resultHandler:^(NSData *_Nullable imageData,
1864+
NSString *_Nullable dataUTI,
1865+
UIImageOrientation orientation,
1866+
NSDictionary *_Nullable info) {
1867+
dataHandler(imageData, info);
1868+
}];
1869+
#else
1870+
dataHandler(nil, @{PHImageErrorKey: [NSError errorWithDomain:@"PMPhotoManager" code:-1 userInfo:@{
1871+
NSLocalizedDescriptionKey: @"requestImageDataForAsset unavailable on this platform version."
1872+
}]});
1873+
#endif
1874+
}
1875+
});
1876+
}
1877+
17431878
- (void)fetchFullSizeImageFile:(PHAsset *)asset
17441879
resultHandler:(PMResultHandler *)handler
17451880
progressHandler:(NSObject <PMProgressHandlerProtocol> *)progressHandler {

0 commit comments

Comments
 (0)