Skip to content

Commit 9b7e54c

Browse files
committed
Persistent photo cache: two-tier disk storage for chat attachments (#511)
<!-- Macroscope's pull request summary starts here --> <!-- Macroscope will only edit the content between these invisible markers, and the markers themselves will not be visible in the GitHub rendered markdown. --> <!-- If you delete either of the start / end markers from your PR's description, Macroscope will append its summary at the bottom of the description. --> ### Add persistent photo cache and update chat attachment writes to use `ImageCache.cacheImage(_:for:storageTier:)` with `.persistent` in [MessagesGroupItemView.swift](https://github.com/xmtplabs/convos-ios/pull/511/files#diff-34b33cae925fe553d357af97d04319b8de2de329c1903afe72c915e36aa98774) Introduce two-tier image storage via `ImageStorageTier`, add persistent disk directory in `ImageCache`, update cache write APIs to accept `storageTier`, and route chat attachment and outgoing image writes to `.persistent`. Provide removal APIs for persistent images and prefer persistent tier on disk load. #### 📍Where to Start Start with the tiered storage design and API in `ImageCache` in [ImageCache.swift](https://github.com/xmtplabs/convos-ios/pull/511/files#diff-9c43db5e1255cd76ef671f19b4a4c7a02209590452cefbe49fbee60b9884b12b). <!-- Macroscope's review summary starts here --> <sup><a href="https://app.macroscope.com">Macroscope</a> summarized c4f4ffe.</sup> <!-- Macroscope's review summary ends here --> <!-- macroscope-ui-refresh --> <!-- Macroscope's pull request summary ends here -->
1 parent dbbbfe7 commit 9b7e54c

7 files changed

Lines changed: 526 additions & 59 deletions

File tree

Convos/Conversation Detail/Messages/MessagesListView/MessagesGroupItemView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ private struct AttachmentPlaceholder: View {
321321

322322
if let image = UIImage(data: imageData) {
323323
loadedImage = image
324-
ImageCache.shared.cacheImage(image, for: cacheKey)
324+
ImageCache.shared.cacheImage(image, for: cacheKey, storageTier: .persistent)
325325

326326
if attachment.width == nil {
327327
onDimensionsLoaded(Int(image.size.width), Int(image.size.height))

ConvosCore/Sources/ConvosCore/Image Cache/ImageCache.swift

Lines changed: 154 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,19 @@ public enum ImageFormat: Sendable {
1919
}
2020
}
2121

22+
// MARK: - Image Storage Tier
23+
24+
/// Controls where images are stored on disk
25+
public enum ImageStorageTier: Sendable {
26+
/// LRU-evictable cache for re-fetchable images (avatars, group images, QR codes).
27+
/// Stored in Caches/ which iOS may purge under storage pressure.
28+
case cache
29+
30+
/// Persistent storage for chat photo attachments that cannot be re-fetched.
31+
/// Stored in Application Support/ which is not purged by iOS.
32+
case persistent
33+
}
34+
2235
// MARK: - ImageCacheable Protocol
2336

2437
/// Protocol for objects that can have their images cached.
@@ -156,6 +169,7 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
156169

157170
// Disk cache properties
158171
private let diskCacheURL: URL
172+
private let persistentCacheURL: URL
159173
private let diskCacheQueue: DispatchQueue
160174
private let fileManager: FileManager
161175
private let maxDiskCacheSize: Int = CacheConfiguration.maxDiskCacheSize
@@ -212,15 +226,20 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
212226
fileManager = FileManager.default
213227
diskCacheQueue = DispatchQueue(label: "com.convos.imagecache.disk", qos: .utility)
214228

215-
// Create disk cache directory
229+
// Create evictable disk cache directory (Caches/ - iOS may purge under storage pressure)
216230
let cacheDir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)[0]
217231
diskCacheURL = cacheDir.appendingPathComponent("ImageCache", isDirectory: true)
218232

219-
do {
220-
try fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
221-
} catch {
222-
Log.error("Failed to create disk cache directory: \(error)")
223-
// Fallback: Memory-only caching will still work, but disk operations will fail gracefully
233+
// Create persistent photo store (Application Support/ - not purged by iOS)
234+
let appSupportDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
235+
persistentCacheURL = appSupportDir.appendingPathComponent("PhotoStore", isDirectory: true)
236+
237+
for dir in [diskCacheURL, persistentCacheURL] {
238+
do {
239+
try fileManager.createDirectory(at: dir, withIntermediateDirectories: true)
240+
} catch {
241+
Log.error("Failed to create directory \(dir.lastPathComponent): \(error)")
242+
}
224243
}
225244

226245
// Clean up disk cache on init if needed
@@ -674,6 +693,75 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
674693
cacheUpdateSubject.send(identifier)
675694
}
676695

696+
// MARK: - Persistent Storage (chat photo attachments)
697+
698+
public func cacheData(_ data: Data, for identifier: String, storageTier: ImageStorageTier) {
699+
guard let image = UIImage(data: data) else {
700+
Log.error("Failed to create UIImage from data for persistent cache: \(identifier)")
701+
return
702+
}
703+
let cost = memoryCost(for: image)
704+
cache.setObject(image, forKey: identifier as NSString, cost: cost)
705+
706+
Task {
707+
await saveDataToDisk(data, identifier: identifier, storageTier: storageTier)
708+
}
709+
710+
cacheUpdateSubject.send(identifier)
711+
}
712+
713+
public func cacheImage(_ image: UIImage, for identifier: String, storageTier: ImageStorageTier) {
714+
let cost = memoryCost(for: image)
715+
cache.setObject(image, forKey: identifier as NSString, cost: cost)
716+
717+
Task {
718+
await saveImageToDisk(image, identifier: identifier, storageTier: storageTier)
719+
}
720+
721+
cacheUpdateSubject.send(identifier)
722+
}
723+
724+
public func removePersistentImages(for identifiers: [String]) {
725+
guard !identifiers.isEmpty else { return }
726+
for identifier in identifiers {
727+
cache.removeObject(forKey: identifier as NSString)
728+
}
729+
Task {
730+
await performDiskOperation { cache in
731+
for identifier in identifiers {
732+
for ext in [".jpg", ".png"] {
733+
let fileURL = cache.persistentCacheURL.appendingPathComponent(
734+
cache.sanitizedFilename(for: identifier, fileExtension: ext)
735+
)
736+
guard cache.fileManager.fileExists(atPath: fileURL.path) else { continue }
737+
do {
738+
try cache.fileManager.removeItem(at: fileURL)
739+
} catch {
740+
Log.error("Failed to remove persistent image: \(identifier) - \(error)")
741+
}
742+
}
743+
}
744+
}
745+
}
746+
}
747+
748+
public func removeAllPersistentImages() {
749+
cache.removeAllObjects()
750+
Task {
751+
await performDiskOperation { cache in
752+
guard let contents = try? cache.fileManager.contentsOfDirectory(
753+
at: cache.persistentCacheURL, includingPropertiesForKeys: nil
754+
) else { return }
755+
for fileURL in contents {
756+
do { try cache.fileManager.removeItem(at: fileURL) } catch {
757+
Log.error("Failed to remove persistent image: \(fileURL.lastPathComponent) - \(error)")
758+
}
759+
}
760+
Log.info("Removed all persistent images (\(contents.count) files)")
761+
}
762+
}
763+
}
764+
677765
// MARK: - Disk Cache Helpers
678766

679767
/// Perform an operation on the disk cache queue with proper weak self handling
@@ -712,42 +800,49 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
712800

713801
/// Load image from disk cache
714802
private func loadImageFromDisk(identifier: String, imageFormat: ImageFormat) async -> UIImage? {
715-
let fileURL = diskCacheURL.appendingPathComponent(sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension))
716-
let formatName = imageFormat == .png ? "PNG" : "JPEG"
803+
let filename = sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension)
804+
let persistentURL = persistentCacheURL.appendingPathComponent(filename)
805+
let cacheURL = diskCacheURL.appendingPathComponent(filename)
717806

718807
return await performDiskOperation(default: nil) { cache in
719-
guard cache.fileManager.fileExists(atPath: fileURL.path) else {
720-
return nil
721-
}
722-
723-
do {
724-
let data = try Data(contentsOf: fileURL)
725-
if let image = UIImage(data: data) {
726-
var mutableFileURL = fileURL
808+
for fileURL in [persistentURL, cacheURL] {
809+
guard cache.fileManager.fileExists(atPath: fileURL.path) else { continue }
810+
do {
811+
let data = try Data(contentsOf: fileURL)
812+
guard let image = UIImage(data: data) else {
813+
Log.error("Failed to decode image from disk: \(identifier)")
814+
continue
815+
}
816+
var mutableURL = fileURL
727817
var resourceValues = URLResourceValues()
728818
resourceValues.contentAccessDate = Date()
729-
try? mutableFileURL.setResourceValues(resourceValues)
730-
Log.debug("Successfully loaded \(formatName) image from disk: \(identifier)")
819+
try? mutableURL.setResourceValues(resourceValues)
731820
return image
732-
} else {
733-
Log.error("Failed to decode image from disk: \(identifier)")
734-
return nil
821+
} catch {
822+
Log.error("Failed to load image from disk: \(identifier) - \(error)")
735823
}
736-
} catch {
737-
Log.error("Failed to load image from disk: \(identifier) - \(error)")
738-
return nil
739824
}
825+
return nil
740826
}
741827
}
742828

743-
/// Save pre-compressed data directly to disk cache (avoids double compression)
744-
/// - Parameters:
745-
/// - data: The image data to save
746-
/// - identifier: The cache identifier
747-
/// - imageFormat: The image format (default: .jpg)
748-
/// - forceOverwrite: If true, overwrite existing file (used when URL changes)
749-
private func saveDataToDisk(_ data: Data, identifier: String, imageFormat: ImageFormat = .jpg, forceOverwrite: Bool = false) async {
750-
let fileURL = diskCacheURL.appendingPathComponent(sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension))
829+
private func directoryURL(for tier: ImageStorageTier) -> URL {
830+
switch tier {
831+
case .cache: return diskCacheURL
832+
case .persistent: return persistentCacheURL
833+
}
834+
}
835+
836+
/// Save pre-compressed data directly to disk (avoids double compression)
837+
private func saveDataToDisk(
838+
_ data: Data,
839+
identifier: String,
840+
imageFormat: ImageFormat = .jpg,
841+
forceOverwrite: Bool = false,
842+
storageTier: ImageStorageTier = .cache
843+
) async {
844+
let dir = directoryURL(for: storageTier)
845+
let fileURL = dir.appendingPathComponent(sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension))
751846

752847
await performDiskOperation { cache in
753848
if !forceOverwrite && cache.fileManager.fileExists(atPath: fileURL.path) {
@@ -756,24 +851,26 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
756851

757852
do {
758853
try data.write(to: fileURL, options: .atomic)
759-
let formatName = imageFormat == .png ? "PNG" : "JPEG"
760-
Log.debug("Successfully saved image data to disk: \(identifier) (\(data.count) bytes, format: \(formatName))")
761-
cache.scheduleCleanupIfNeeded()
854+
if storageTier == .cache {
855+
cache.scheduleCleanupIfNeeded()
856+
}
762857
} catch {
763858
Log.error("Failed to save image data to disk: \(identifier) - \(error)")
764859
}
765860
}
766861
}
767862

768-
/// Save image to disk cache
769-
/// - Parameters:
770-
/// - image: The image to save
771-
/// - identifier: The cache identifier
772-
/// - imageFormat: The image format (default: .jpg)
773-
/// - forceOverwrite: If true, overwrite existing file (used when URL changes)
774-
private func saveImageToDisk(_ image: UIImage, identifier: String, imageFormat: ImageFormat = .jpg, forceOverwrite: Bool = false) async {
863+
/// Save image to disk
864+
private func saveImageToDisk(
865+
_ image: UIImage,
866+
identifier: String,
867+
imageFormat: ImageFormat = .jpg,
868+
forceOverwrite: Bool = false,
869+
storageTier: ImageStorageTier = .cache
870+
) async {
775871
await performDiskOperation { cache in
776-
let fileURL = cache.diskCacheURL.appendingPathComponent(cache.sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension))
872+
let dir = cache.directoryURL(for: storageTier)
873+
let fileURL = dir.appendingPathComponent(cache.sanitizedFilename(for: identifier, fileExtension: imageFormat.fileExtension))
777874

778875
if !forceOverwrite && cache.fileManager.fileExists(atPath: fileURL.path) {
779876
return
@@ -791,30 +888,33 @@ public final class ImageCache: ImageCacheProtocol, @unchecked Sendable {
791888

792889
do {
793890
try imageData.write(to: fileURL, options: .atomic)
794-
let formatName = imageFormat == .png ? "PNG" : "JPEG"
795-
Log.debug("Successfully saved image to disk: \(identifier) (\(imageData.count) bytes, format: \(formatName))")
796-
cache.scheduleCleanupIfNeeded()
891+
if storageTier == .cache {
892+
cache.scheduleCleanupIfNeeded()
893+
}
797894
} catch {
798895
Log.error("Failed to save image to disk: \(identifier) - \(error)")
799896
}
800897
}
801898
}
802899

803-
/// Remove image from disk cache
804-
/// Removes both PNG and JPEG versions if they exist (for backward compatibility)
900+
/// Remove image from both disk cache and persistent store
805901
private func removeImageFromDisk(identifier: String) async {
806-
let pngURL = diskCacheURL.appendingPathComponent(sanitizedFilename(for: identifier, fileExtension: ".png"))
807-
let jpgURL = diskCacheURL.appendingPathComponent(sanitizedFilename(for: identifier, fileExtension: ".jpg"))
902+
let filename = sanitizedFilename(for: identifier, fileExtension: ".jpg")
903+
let pngFilename = sanitizedFilename(for: identifier, fileExtension: ".png")
808904

809905
await performDiskOperation { cache in
810-
for (url, format) in [(pngURL, "PNG"), (jpgURL, "JPEG")]
811-
where cache.fileManager.fileExists(atPath: url.path) {
906+
let urls = [
907+
cache.diskCacheURL.appendingPathComponent(filename),
908+
cache.diskCacheURL.appendingPathComponent(pngFilename),
909+
cache.persistentCacheURL.appendingPathComponent(filename),
910+
cache.persistentCacheURL.appendingPathComponent(pngFilename),
911+
]
912+
for url in urls where cache.fileManager.fileExists(atPath: url.path) {
812913
do {
813914
try cache.fileManager.removeItem(at: url)
814-
Log.debug("Successfully removed \(format) image from disk: \(identifier)")
815915
} catch {
816916
if (error as NSError).code != NSFileNoSuchFileError {
817-
Log.error("Failed to remove \(format) image from disk: \(identifier) - \(error)")
917+
Log.error("Failed to remove image from disk: \(url.lastPathComponent) - \(error)")
818918
}
819919
}
820920
}

ConvosCore/Sources/ConvosCore/Image Cache/ImageCacheProtocol.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ public protocol ImageCacheProtocol: AnyObject, Sendable {
4848
/// Remove cached image by identifier
4949
func removeImage(for identifier: String)
5050

51+
// MARK: - Persistent Storage (chat photo attachments)
52+
53+
/// Cache image data to a specific storage tier
54+
func cacheData(_ data: Data, for identifier: String, storageTier: ImageStorageTier)
55+
56+
/// Cache image to a specific storage tier
57+
func cacheImage(_ image: ImageType, for identifier: String, storageTier: ImageStorageTier)
58+
59+
/// Remove persistent images by their identifiers (used for conversation cleanup)
60+
func removePersistentImages(for identifiers: [String])
61+
62+
/// Remove all persistent images (used for "Delete All Data")
63+
func removeAllPersistentImages()
64+
5165
// MARK: - URL Change Detection
5266

5367
/// Check if the URL has changed for an identifier (without updating tracker)

ConvosCore/Sources/ConvosCore/Image Cache/MockImageCache.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ public final class MockImageCache: ImageCacheProtocol, @unchecked Sendable {
3939

4040
public func removeImage(for identifier: String) {}
4141

42+
// MARK: - Persistent Storage
43+
44+
public func cacheData(_ data: Data, for identifier: String, storageTier: ImageStorageTier) {}
45+
46+
public func cacheImage(_ image: ImageType, for identifier: String, storageTier: ImageStorageTier) {}
47+
48+
public func removePersistentImages(for identifiers: [String]) {}
49+
50+
public func removeAllPersistentImages() {}
51+
4252
// MARK: - URL Change Detection
4353

4454
public func hasURLChanged(_ url: String?, for identifier: String) async -> Bool { false }

ConvosCore/Sources/ConvosCore/Inboxes/InboxStateMachine.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,15 +866,23 @@ public actor InboxStateMachine {
866866

867867
Log.info("Cleaning up all data for inbox clientId: \(clientId)")
868868

869-
try await databaseWriter.write { db in
869+
let attachmentKeys: [String] = try await databaseWriter.write { db in
870870
let conversationIds = try DBConversation
871871
.filter(DBConversation.Columns.clientId == clientId)
872872
.fetchAll(db)
873873
.map { $0.id }
874874

875875
Log.info("Found \(conversationIds.count) conversations to clean up for inbox clientId: \(clientId)")
876876

877+
var allAttachmentKeys: [String] = []
877878
for conversationId in conversationIds {
879+
let messages = try DBMessage
880+
.filter(DBMessage.Columns.conversationId == conversationId)
881+
.fetchAll(db)
882+
for message in messages {
883+
allAttachmentKeys.append(contentsOf: message.attachmentUrls)
884+
}
885+
878886
try DBMessage.filter(DBMessage.Columns.conversationId == conversationId).deleteAll(db)
879887
try DBConversationMember.filter(DBConversationMember.Columns.conversationId == conversationId).deleteAll(db)
880888
try ConversationLocalState.filter(ConversationLocalState.Columns.conversationId == conversationId).deleteAll(db)
@@ -890,6 +898,12 @@ public actor InboxStateMachine {
890898
try DBInbox.filter(DBInbox.Columns.clientId == clientId).deleteAll(db)
891899

892900
Log.info("Successfully cleaned up all data for inbox clientId: \(clientId)")
901+
return allAttachmentKeys
902+
}
903+
904+
if !attachmentKeys.isEmpty {
905+
Log.info("Removing \(attachmentKeys.count) persistent photo(s) for inbox clientId: \(clientId)")
906+
ImageCacheContainer.shared.removePersistentImages(for: attachmentKeys)
893907
}
894908
}
895909

0 commit comments

Comments
 (0)