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..5239702d4b --- /dev/null +++ b/mobile/ios/WidgetExtension/ImageEntry.swift @@ -0,0 +1,161 @@ +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: EntryMetadata( + 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.png") + let metadataURL = containerURL.appendingPathComponent( + "\(key)_metadata.json" + ) + + // 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.png") + let metadataURL = containerURL.appendingPathComponent( + "\(key)_metadata.json" + ) + + guard let imageData = try? Data(contentsOf: imageURL), + let metadataJSON = try? Data(contentsOf: metadataURL), + 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..24072b0607 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: EntryMetadata( + 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..da7b887c37 100644 --- a/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift +++ b/mobile/ios/WidgetExtension/widgets/MemoryWidget.swift @@ -19,21 +19,23 @@ struct ImmichMemoryProvider: TimelineProvider { in context: Context, completion: @escaping @Sendable (ImageEntry) -> Void ) { + let cacheKey = "memory_\(context.family.rawValue)" + Task { guard let api = try? await ImmichAPI() else { - completion(ImageEntry(date: Date(), image: nil, error: .noLogin)) + completion(ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first!) return } guard let memories = try? await api.fetchMemory(for: Date.now) else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) 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, @@ -50,20 +52,14 @@ struct ImmichMemoryProvider: TimelineProvider { guard let randomImage = try? await api.fetchSearchResults( with: SearchFilters(size: 1) - ).first - else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) - return - } - - guard - var imageEntry = try? await buildEntry( + ).first, + var imageEntry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - completion(ImageEntry(date: Date(), image: nil, error: .fetchFailed)) + completion(ImageEntry.handleCacheFallback(for: cacheKey).entries.first!) return } @@ -80,9 +76,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 +94,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 +131,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 +140,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..8f9143cedd 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( @@ -63,26 +63,23 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { ) async -> ImageEntry { + let cacheKey = "random_none_\(context.family.rawValue)" + guard let api = try? await ImmichAPI() else { - return ImageEntry(date: Date(), image: nil, error: .noLogin) + return ImageEntry.handleCacheFallback(for: cacheKey, error: .noLogin).entries.first! } guard let randomImage = try? await api.fetchSearchResults( with: SearchFilters(size: 1) - ).first - else { - return ImageEntry(date: Date(), image: nil, error: .fetchFailed) - } - - guard - var entry = try? await buildEntry( + ).first, + var entry = try? await ImageEntry.build( api: api, asset: randomImage, dateOffset: 0 ) else { - return ImageEntry(date: Date(), image: nil, error: .fetchFailed) + return ImageEntry.handleCacheFallback(for: cacheKey).entries.first! } entry.resize() @@ -99,30 +96,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 +135,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 +145,9 @@ struct ImmichRandomProvider: AppIntentTimelineProvider { entries[i].resize() } + // cache the last image + try? entries.last!.cache(for: cacheKey) + return Timeline(entries: entries, policy: .atEnd) } }