From e211b47fadb7fa286d597d173507ff718deaacdc Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 8 Jul 2025 12:16:30 -0500 Subject: [PATCH] cache the last image an ios widget fetched and use if a fetch fails in a future timeline build --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 +- .../ios/WidgetExtension/EntryGenerators.swift | 59 ------ mobile/ios/WidgetExtension/ImageEntry.swift | 170 ++++++++++++++++++ .../ios/WidgetExtension/ImageWidgetView.swift | 31 +--- mobile/ios/WidgetExtension/ImmichAPI.swift | 33 ++-- .../ios/WidgetExtension/UIImage+Resize.swift | 21 ++- .../widgets/MemoryWidget.swift | 51 ++++-- .../widgets/RandomWidget.swift | 59 +++--- 8 files changed, 298 insertions(+), 138 deletions(-) delete mode 100644 mobile/ios/WidgetExtension/EntryGenerators.swift create mode 100644 mobile/ios/WidgetExtension/ImageEntry.swift diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index fb0908e8b6..1a39f98db3 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 54; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ @@ -117,8 +117,6 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; - exceptions = ( - ); path = Sync; sourceTree = ""; }; @@ -473,10 +471,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -505,10 +507,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/mobile/ios/WidgetExtension/EntryGenerators.swift b/mobile/ios/WidgetExtension/EntryGenerators.swift deleted file mode 100644 index 81bcc2263e..0000000000 --- a/mobile/ios/WidgetExtension/EntryGenerators.swift +++ /dev/null @@ -1,59 +0,0 @@ -import SwiftUI -import WidgetKit - -func buildEntry( - api: ImmichAPI, - asset: Asset, - dateOffset: Int, - subtitle: String? = nil -) - async throws -> ImageEntry -{ - let entryDate = Calendar.current.date( - byAdding: .minute, - value: dateOffset * 20, - to: Date.now - )! - let image = try await api.fetchImage(asset: asset) - - return ImageEntry(date: entryDate, image: image, subtitle: subtitle, deepLink: asset.deepLink) -} - -func generateRandomEntries( - api: ImmichAPI, - now: Date, - count: Int, - albumId: String? = nil, - subtitle: String? = nil -) - async throws -> [ImageEntry] -{ - - var entries: [ImageEntry] = [] - let albumIds = albumId != nil ? [albumId!] : [] - - let randomAssets = try await api.fetchSearchResults( - with: SearchFilters(size: count, albumIds: albumIds) - ) - - await withTaskGroup(of: ImageEntry?.self) { group in - for (dateOffset, asset) in randomAssets.enumerated() { - group.addTask { - return try? await buildEntry( - api: api, - asset: asset, - dateOffset: dateOffset, - subtitle: subtitle - ) - } - } - - for await result in group { - if let entry = result { - entries.append(entry) - } - } - } - - return entries -} diff --git a/mobile/ios/WidgetExtension/ImageEntry.swift b/mobile/ios/WidgetExtension/ImageEntry.swift new file mode 100644 index 0000000000..f3a64f089b --- /dev/null +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -0,0 +1,170 @@ +import SwiftUI +import WidgetKit + +typealias EntryMetadata = ImageEntry.Metadata + +struct ImageEntry: TimelineEntry { + let date: Date + var image: UIImage? + var metadata: Metadata = Metadata() + + struct Metadata: Codable { + var subtitle: String? = nil + var error: WidgetError? = nil + var deepLink: URL? = nil + } + + // Resizes the stored image to a maximum width of 450 pixels + mutating func resize() { + if image == nil || image!.size.height < 450 || image!.size.width < 450 { + return + } + + image = image?.resized(toWidth: 450) + + if image == nil { + metadata.error = .unableToResize + } + } + + static func build( + api: ImmichAPI, + asset: Asset, + dateOffset: Int, + subtitle: String? = nil + ) + async throws -> Self + { + let entryDate = Calendar.current.date( + byAdding: .minute, + value: dateOffset * 20, + to: Date.now + )! + let image = try await api.fetchImage(asset: asset) + + return Self( + date: entryDate, + image: image, + metadata: ImageEntry.Metadata( + subtitle: subtitle, + deepLink: asset.deepLink + ) + ) + } + + func cache(for key: String) throws { + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP + ) { + let imageURL = containerURL.appendingPathComponent("\(key)_image.txt") + let metadataURL = containerURL.appendingPathComponent( + "\(key)_metadata.txt" + ) + + // build metadata JSON + let entryMetadata = try JSONEncoder().encode(self.metadata) + + // write to disk + try self.image?.pngData()?.write(to: imageURL, options: .atomic) + try entryMetadata.write(to: metadataURL, options: .atomic) + } + } + + static func loadCached(for key: String, at date: Date = Date.now) + -> ImageEntry? + { + if let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP + ) { + let imageURL = containerURL.appendingPathComponent("\(key)_image.txt") + let metadataURL = containerURL.appendingPathComponent( + "\(key)_metadata.txt" + ) + + guard let imageData = try? Data(contentsOf: imageURL), + let metadataJSON = try? Data(contentsOf: metadataURL) + else { + // cache miss + return nil + } + + guard + let decodedMetadata = try? JSONDecoder().decode( + Metadata.self, + from: metadataJSON + ) + else { + return nil + } + + return ImageEntry( + date: date, + image: UIImage(data: imageData), + metadata: decodedMetadata + ) + } + + return nil + } + + static func handleCacheFallback( + for key: String, + error: WidgetError = .fetchFailed + ) -> Timeline { + var timelineEntry = ImageEntry( + date: Date.now, + image: nil, + metadata: EntryMetadata(error: error) + ) + + // skip cache if album not found or no login + // we want to show these errors to the user since without intervention, + // it will never succeed + if error != .noLogin && error != .albumNotFound { + if let cachedEntry = ImageEntry.loadCached(for: key) { + timelineEntry = cachedEntry + } + } + + return Timeline(entries: [timelineEntry], policy: .atEnd) + } +} + +func generateRandomEntries( + api: ImmichAPI, + now: Date, + count: Int, + albumId: String? = nil, + subtitle: String? = nil +) + async throws -> [ImageEntry] +{ + + var entries: [ImageEntry] = [] + let albumIds = albumId != nil ? [albumId!] : [] + + let randomAssets = try await api.fetchSearchResults( + with: SearchFilters(size: count, albumIds: albumIds) + ) + + await withTaskGroup(of: ImageEntry?.self) { group in + for (dateOffset, asset) in randomAssets.enumerated() { + group.addTask { + return try? await ImageEntry.build( + api: api, + asset: asset, + dateOffset: dateOffset, + subtitle: subtitle + ) + } + } + + for await result in group { + if let entry = result { + entries.append(entry) + } + } + } + + return entries +} diff --git a/mobile/ios/WidgetExtension/ImageWidgetView.swift b/mobile/ios/WidgetExtension/ImageWidgetView.swift index a4bda9d845..a40bf38506 100644 --- a/mobile/ios/WidgetExtension/ImageWidgetView.swift +++ b/mobile/ios/WidgetExtension/ImageWidgetView.swift @@ -1,27 +1,6 @@ import SwiftUI import WidgetKit -struct ImageEntry: TimelineEntry { - let date: Date - var image: UIImage? - var subtitle: String? = nil - var error: WidgetError? = nil - var deepLink: URL? = nil - - // Resizes the stored image to a maximum width of 450 pixels - mutating func resize() { - if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) { - return - } - - image = image?.resized(toWidth: 450) - - if image == nil { - error = .unableToResize - } - } -} - struct ImmichWidgetView: View { var entry: ImageEntry @@ -29,7 +8,7 @@ struct ImmichWidgetView: View { if entry.image == nil { VStack { Image("LaunchImage") - Text(entry.error?.errorDescription ?? "") + Text(entry.metadata.error?.errorDescription ?? "") .minimumScaleFactor(0.25) .multilineTextAlignment(.center) .foregroundStyle(.secondary) @@ -44,7 +23,7 @@ struct ImmichWidgetView: View { ) VStack { Spacer() - if let subtitle = entry.subtitle { + if let subtitle = entry.metadata.subtitle { Text(subtitle) .foregroundColor(.white) .padding(8) @@ -55,7 +34,7 @@ struct ImmichWidgetView: View { } .padding(16) } - .widgetURL(entry.deepLink) + .widgetURL(entry.metadata.deepLink) } } } @@ -70,7 +49,9 @@ struct ImmichWidgetView: View { ImageEntry( date: date, image: UIImage(named: "ImmichLogo"), - subtitle: "1 year ago" + metadata: ImageEntry.Metadata( + subtitle: "1 year ago" + ) ) } ) diff --git a/mobile/ios/WidgetExtension/ImmichAPI.swift b/mobile/ios/WidgetExtension/ImmichAPI.swift index 65fc27be77..7cefd9d5ee 100644 --- a/mobile/ios/WidgetExtension/ImmichAPI.swift +++ b/mobile/ios/WidgetExtension/ImmichAPI.swift @@ -2,7 +2,7 @@ import Foundation import SwiftUI import WidgetKit -enum WidgetError: Error { +enum WidgetError: Error, Codable { case noLogin case fetchFailed case unknown @@ -23,10 +23,10 @@ extension WidgetError: LocalizedError { case .albumNotFound: return "Album not found" - + case .invalidURL: return "An invalid URL was used" - + case .invalidImage: return "An invalid image was received" @@ -46,7 +46,7 @@ enum AssetType: String, Codable { struct Asset: Codable { let id: String let type: AssetType - + var deepLink: URL? { return URL(string: "immich://asset?id=\(id)") } @@ -75,6 +75,8 @@ struct Album: Codable { let albumName: String } +let IMMICH_SHARE_GROUP = "group.app.immich.share" + // MARK: API class ImmichAPI { @@ -86,7 +88,7 @@ class ImmichAPI { init() async throws { // fetch the credentials from the UserDefaults store that dart placed here - guard let defaults = UserDefaults(suiteName: "group.app.immich.share"), + guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP), let serverURL = defaults.string(forKey: "widget_server_url"), let sessionKey = defaults.string(forKey: "widget_auth_token") else { @@ -189,18 +191,25 @@ class ImmichAPI { else { throw .invalidURL } - - guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else { + + guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) + else { throw .invalidURL } let decodeOptions: [NSString: Any] = [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 400, - kCGImageSourceCreateThumbnailWithTransform: true + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceThumbnailMaxPixelSize: 400, + kCGImageSourceCreateThumbnailWithTransform: true, ] - - guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else { + + guard + let thumbnail = CGImageSourceCreateThumbnailAtIndex( + imageSource, + 0, + decodeOptions as CFDictionary + ) + else { throw .fetchFailed } diff --git a/mobile/ios/WidgetExtension/UIImage+Resize.swift b/mobile/ios/WidgetExtension/UIImage+Resize.swift index 40bb9e2ace..030f354ca4 100644 --- a/mobile/ios/WidgetExtension/UIImage+Resize.swift +++ b/mobile/ios/WidgetExtension/UIImage+Resize.swift @@ -7,14 +7,17 @@ import UIKit extension UIImage { - /// Crops the image to ensure width and height do not exceed maxSize. - /// Keeps original aspect ratio and crops excess equally from edges (center crop). - func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { - let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height))) - let format = imageRendererFormat - format.opaque = isOpaque - return UIGraphicsImageRenderer(size: canvas, format: format).image { - _ in draw(in: CGRect(origin: .zero, size: canvas)) - } + /// Crops the image to ensure width and height do not exceed maxSize. + /// Keeps original aspect ratio and crops excess equally from edges (center crop). + func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? { + let canvas = CGSize( + width: width, + height: CGFloat(ceil(width / size.width * size.height)) + ) + let format = imageRendererFormat + format.opaque = isOpaque + return UIGraphicsImageRenderer(size: canvas, format: format).image { + _ in draw(in: CGRect(origin: .zero, size: canvas)) } + } } diff --git a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift index 516bf6905e..e9b6626b0e 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -21,19 +21,31 @@ struct ImmichMemoryProvider: TimelineProvider { ) { Task { guard let api = try? await ImmichAPI() else { - completion(ImageEntry(date: Date(), image: nil, error: .noLogin)) + completion( + ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .noLogin) + ) + ) return } guard let memories = try? await api.fetchMemory(for: Date.now) else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + completion( + ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .fetchFailed) + ) + ) return } for memory in memories { if let asset = memory.assets.first(where: { $0.type == .image }), - var entry = try? await buildEntry( + var entry = try? await ImageEntry.build( api: api, asset: asset, dateOffset: 0, @@ -52,18 +64,30 @@ struct ImmichMemoryProvider: TimelineProvider { with: SearchFilters(size: 1) ).first else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + completion( + ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .fetchFailed) + ) + ) return } guard - var imageEntry = try? await buildEntry( + var imageEntry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + completion( + ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .fetchFailed) + ) + ) return } @@ -80,9 +104,12 @@ struct ImmichMemoryProvider: TimelineProvider { var entries: [ImageEntry] = [] let now = Date() + let cacheKey = "memory_\(context.family.rawValue)" + guard let api = try? await ImmichAPI() else { - entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) - completion(Timeline(entries: entries, policy: .atEnd)) + completion( + ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) + ) return } @@ -95,7 +122,7 @@ struct ImmichMemoryProvider: TimelineProvider { for asset in memory.assets { if asset.type == .image && totalAssets < 12 { group.addTask { - try? await buildEntry( + try? await ImageEntry.build( api: api, asset: asset, dateOffset: totalAssets, @@ -132,7 +159,8 @@ struct ImmichMemoryProvider: TimelineProvider { // If we fail to fetch images, we still want to add an entry // with a nil image and an error if entries.count == 0 { - entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) + completion(ImageEntry.handleCacheFallback(for: cacheKey)) + return } // Resize all images to something that can be stored by iOS @@ -140,6 +168,9 @@ struct ImmichMemoryProvider: TimelineProvider { entries[i].resize() } + // cache the last image + try? entries.last!.cache(for: cacheKey) + completion(Timeline(entries: entries, policy: .atEnd)) } } diff --git a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift index 99968c4baa..8436e27914 100644 --- a/mobile/ios/WidgetExtension/widgets/RandomWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/RandomWidget.swift @@ -45,7 +45,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent { @Parameter(title: "Album") var album: Album? - + @Parameter(title: "Show Album Name", default: false) var showAlbumName: Bool } @@ -54,7 +54,7 @@ struct RandomConfigurationAppIntent: WidgetConfigurationIntent { struct ImmichRandomProvider: AppIntentTimelineProvider { func placeholder(in context: Context) -> ImageEntry { - ImageEntry(date: Date(), image: nil) + ImageEntry(date: Date()) } func snapshot( @@ -64,7 +64,11 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { -> ImageEntry { guard let api = try? await ImmichAPI() else { - return ImageEntry(date: Date(), image: nil, error: .noLogin) + return ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .noLogin) + ) } guard @@ -72,17 +76,25 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { with: SearchFilters(size: 1) ).first else { - return ImageEntry(date: Date(), image: nil, error: .fetchFailed) + return ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .fetchFailed) + ) } guard - var entry = try? await buildEntry( + var entry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - return ImageEntry(date: Date(), image: nil, error: .fetchFailed) + return ImageEntry( + date: Date(), + image: nil, + metadata: EntryMetadata(error: .fetchFailed) + ) } entry.resize() @@ -99,30 +111,34 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { var entries: [ImageEntry] = [] let now = Date() - // If we don't have a server config, return an entry with an error - guard let api = try? await ImmichAPI() else { - entries.append(ImageEntry(date: now, image: nil, error: .noLogin)) - return Timeline(entries: entries, policy: .atEnd) - } - // nil if album is NONE or nil let albumId = configuration.album?.id != "NONE" ? configuration.album?.id : nil - var albumName: String? = albumId != nil ? configuration.album?.albumName : nil - + let albumName: String? = + albumId != nil ? configuration.album?.albumName : nil + + let cacheKey = "random_\(albumId ?? "none")_\(context.family.rawValue)" + + // If we don't have a server config, return an entry with an error + guard let api = try? await ImmichAPI() else { + return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin) + } + if albumId != nil { // make sure the album exists on server, otherwise show error guard let albums = try? await api.fetchAlbums() else { - entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) - return Timeline(entries: entries, policy: .atEnd) + return ImageEntry.handleCacheFallback(for: cacheKey) } if !albums.contains(where: { $0.id == albumId }) { - entries.append(ImageEntry(date: now, image: nil, error: .albumNotFound)) - return Timeline(entries: entries, policy: .atEnd) + return ImageEntry.handleCacheFallback( + for: cacheKey, + error: .albumNotFound + ) } } + // build entries entries.append( contentsOf: (try? await generateRandomEntries( api: api, @@ -134,9 +150,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { ?? [] ) - // If we fail to fetch images, we still want to add an entry with a nil image and an error + // Load or save a cached asset for when network conditions are bad if entries.count == 0 { - entries.append(ImageEntry(date: now, image: nil, error: .fetchFailed)) + return ImageEntry.handleCacheFallback(for: cacheKey) } // Resize all images to something that can be stored by iOS @@ -144,6 +160,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { entries[i].resize() } + // cache the last image + try? entries.last!.cache(for: cacheKey) + return Timeline(entries: entries, policy: .atEnd) } }