@@ -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 }
0 commit comments