Skip to content

Commit a918481

Browse files
authored
feat(mobile): cache latest ios widget entry for fallback (#19824)
* cache the last image an ios widget fetched and use if a fetch fails in a future timeline build * code review fixes * downgrade pbx for flutter * use cache in snapshots
1 parent a201665 commit a918481

File tree

7 files changed

+247
-145
lines changed

7 files changed

+247
-145
lines changed

mobile/ios/WidgetExtension/EntryGenerators.swift

Lines changed: 0 additions & 59 deletions
This file was deleted.
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import SwiftUI
2+
import WidgetKit
3+
4+
typealias EntryMetadata = ImageEntry.Metadata
5+
6+
struct ImageEntry: TimelineEntry {
7+
let date: Date
8+
var image: UIImage?
9+
var metadata: Metadata = Metadata()
10+
11+
struct Metadata: Codable {
12+
var subtitle: String? = nil
13+
var error: WidgetError? = nil
14+
var deepLink: URL? = nil
15+
}
16+
17+
// Resizes the stored image to a maximum width of 450 pixels
18+
mutating func resize() {
19+
if image == nil || image!.size.height < 450 || image!.size.width < 450 {
20+
return
21+
}
22+
23+
image = image?.resized(toWidth: 450)
24+
25+
if image == nil {
26+
metadata.error = .unableToResize
27+
}
28+
}
29+
30+
static func build(
31+
api: ImmichAPI,
32+
asset: Asset,
33+
dateOffset: Int,
34+
subtitle: String? = nil
35+
)
36+
async throws -> Self
37+
{
38+
let entryDate = Calendar.current.date(
39+
byAdding: .minute,
40+
value: dateOffset * 20,
41+
to: Date.now
42+
)!
43+
let image = try await api.fetchImage(asset: asset)
44+
45+
return Self(
46+
date: entryDate,
47+
image: image,
48+
metadata: EntryMetadata(
49+
subtitle: subtitle,
50+
deepLink: asset.deepLink
51+
)
52+
)
53+
}
54+
55+
func cache(for key: String) throws {
56+
if let containerURL = FileManager.default.containerURL(
57+
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
58+
) {
59+
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
60+
let metadataURL = containerURL.appendingPathComponent(
61+
"\(key)_metadata.json"
62+
)
63+
64+
// build metadata JSON
65+
let entryMetadata = try JSONEncoder().encode(self.metadata)
66+
67+
// write to disk
68+
try self.image?.pngData()?.write(to: imageURL, options: .atomic)
69+
try entryMetadata.write(to: metadataURL, options: .atomic)
70+
}
71+
}
72+
73+
static func loadCached(for key: String, at date: Date = Date.now)
74+
-> ImageEntry?
75+
{
76+
if let containerURL = FileManager.default.containerURL(
77+
forSecurityApplicationGroupIdentifier: IMMICH_SHARE_GROUP
78+
) {
79+
let imageURL = containerURL.appendingPathComponent("\(key)_image.png")
80+
let metadataURL = containerURL.appendingPathComponent(
81+
"\(key)_metadata.json"
82+
)
83+
84+
guard let imageData = try? Data(contentsOf: imageURL),
85+
let metadataJSON = try? Data(contentsOf: metadataURL),
86+
let decodedMetadata = try? JSONDecoder().decode(Metadata.self, from: metadataJSON)
87+
else {
88+
return nil
89+
}
90+
91+
return ImageEntry(
92+
date: date,
93+
image: UIImage(data: imageData),
94+
metadata: decodedMetadata
95+
)
96+
}
97+
98+
return nil
99+
}
100+
101+
static func handleCacheFallback(
102+
for key: String,
103+
error: WidgetError = .fetchFailed
104+
) -> Timeline<ImageEntry> {
105+
var timelineEntry = ImageEntry(
106+
date: Date.now,
107+
image: nil,
108+
metadata: EntryMetadata(error: error)
109+
)
110+
111+
// skip cache if album not found or no login
112+
// we want to show these errors to the user since without intervention,
113+
// it will never succeed
114+
if error != .noLogin && error != .albumNotFound {
115+
if let cachedEntry = ImageEntry.loadCached(for: key) {
116+
timelineEntry = cachedEntry
117+
}
118+
}
119+
120+
return Timeline(entries: [timelineEntry], policy: .atEnd)
121+
}
122+
}
123+
124+
func generateRandomEntries(
125+
api: ImmichAPI,
126+
now: Date,
127+
count: Int,
128+
albumId: String? = nil,
129+
subtitle: String? = nil
130+
)
131+
async throws -> [ImageEntry]
132+
{
133+
134+
var entries: [ImageEntry] = []
135+
let albumIds = albumId != nil ? [albumId!] : []
136+
137+
let randomAssets = try await api.fetchSearchResults(
138+
with: SearchFilters(size: count, albumIds: albumIds)
139+
)
140+
141+
await withTaskGroup(of: ImageEntry?.self) { group in
142+
for (dateOffset, asset) in randomAssets.enumerated() {
143+
group.addTask {
144+
return try? await ImageEntry.build(
145+
api: api,
146+
asset: asset,
147+
dateOffset: dateOffset,
148+
subtitle: subtitle
149+
)
150+
}
151+
}
152+
153+
for await result in group {
154+
if let entry = result {
155+
entries.append(entry)
156+
}
157+
}
158+
}
159+
160+
return entries
161+
}
Lines changed: 6 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,14 @@
11
import SwiftUI
22
import WidgetKit
33

4-
struct ImageEntry: TimelineEntry {
5-
let date: Date
6-
var image: UIImage?
7-
var subtitle: String? = nil
8-
var error: WidgetError? = nil
9-
var deepLink: URL? = nil
10-
11-
// Resizes the stored image to a maximum width of 450 pixels
12-
mutating func resize() {
13-
if (image == nil || image!.size.height < 450 || image!.size.width < 450 ) {
14-
return
15-
}
16-
17-
image = image?.resized(toWidth: 450)
18-
19-
if image == nil {
20-
error = .unableToResize
21-
}
22-
}
23-
}
24-
254
struct ImmichWidgetView: View {
265
var entry: ImageEntry
276

287
var body: some View {
298
if entry.image == nil {
309
VStack {
3110
Image("LaunchImage")
32-
Text(entry.error?.errorDescription ?? "")
11+
Text(entry.metadata.error?.errorDescription ?? "")
3312
.minimumScaleFactor(0.25)
3413
.multilineTextAlignment(.center)
3514
.foregroundStyle(.secondary)
@@ -44,7 +23,7 @@ struct ImmichWidgetView: View {
4423
)
4524
VStack {
4625
Spacer()
47-
if let subtitle = entry.subtitle {
26+
if let subtitle = entry.metadata.subtitle {
4827
Text(subtitle)
4928
.foregroundColor(.white)
5029
.padding(8)
@@ -55,7 +34,7 @@ struct ImmichWidgetView: View {
5534
}
5635
.padding(16)
5736
}
58-
.widgetURL(entry.deepLink)
37+
.widgetURL(entry.metadata.deepLink)
5938
}
6039
}
6140
}
@@ -70,7 +49,9 @@ struct ImmichWidgetView: View {
7049
ImageEntry(
7150
date: date,
7251
image: UIImage(named: "ImmichLogo"),
73-
subtitle: "1 year ago"
52+
metadata: EntryMetadata(
53+
subtitle: "1 year ago"
54+
)
7455
)
7556
}
7657
)

mobile/ios/WidgetExtension/ImmichAPI.swift

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import Foundation
22
import SwiftUI
33
import WidgetKit
44

5-
enum WidgetError: Error {
5+
enum WidgetError: Error, Codable {
66
case noLogin
77
case fetchFailed
88
case unknown
@@ -23,10 +23,10 @@ extension WidgetError: LocalizedError {
2323

2424
case .albumNotFound:
2525
return "Album not found"
26-
26+
2727
case .invalidURL:
2828
return "An invalid URL was used"
29-
29+
3030
case .invalidImage:
3131
return "An invalid image was received"
3232

@@ -46,7 +46,7 @@ enum AssetType: String, Codable {
4646
struct Asset: Codable {
4747
let id: String
4848
let type: AssetType
49-
49+
5050
var deepLink: URL? {
5151
return URL(string: "immich://asset?id=\(id)")
5252
}
@@ -75,6 +75,8 @@ struct Album: Codable {
7575
let albumName: String
7676
}
7777

78+
let IMMICH_SHARE_GROUP = "group.app.immich.share"
79+
7880
// MARK: API
7981

8082
class ImmichAPI {
@@ -86,7 +88,7 @@ class ImmichAPI {
8688

8789
init() async throws {
8890
// fetch the credentials from the UserDefaults store that dart placed here
89-
guard let defaults = UserDefaults(suiteName: "group.app.immich.share"),
91+
guard let defaults = UserDefaults(suiteName: IMMICH_SHARE_GROUP),
9092
let serverURL = defaults.string(forKey: "widget_server_url"),
9193
let sessionKey = defaults.string(forKey: "widget_auth_token")
9294
else {
@@ -189,18 +191,25 @@ class ImmichAPI {
189191
else {
190192
throw .invalidURL
191193
}
192-
193-
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil) else {
194+
195+
guard let imageSource = CGImageSourceCreateWithURL(fetchURL as CFURL, nil)
196+
else {
194197
throw .invalidURL
195198
}
196199

197200
let decodeOptions: [NSString: Any] = [
198-
kCGImageSourceCreateThumbnailFromImageAlways: true,
199-
kCGImageSourceThumbnailMaxPixelSize: 400,
200-
kCGImageSourceCreateThumbnailWithTransform: true
201+
kCGImageSourceCreateThumbnailFromImageAlways: true,
202+
kCGImageSourceThumbnailMaxPixelSize: 400,
203+
kCGImageSourceCreateThumbnailWithTransform: true,
201204
]
202-
203-
guard let thumbnail = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions as CFDictionary) else {
205+
206+
guard
207+
let thumbnail = CGImageSourceCreateThumbnailAtIndex(
208+
imageSource,
209+
0,
210+
decodeOptions as CFDictionary
211+
)
212+
else {
204213
throw .fetchFailed
205214
}
206215

mobile/ios/WidgetExtension/UIImage+Resize.swift

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,17 @@
77
import UIKit
88

99
extension UIImage {
10-
/// Crops the image to ensure width and height do not exceed maxSize.
11-
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
12-
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
13-
let canvas = CGSize(width: width, height: CGFloat(ceil(width/size.width * size.height)))
14-
let format = imageRendererFormat
15-
format.opaque = isOpaque
16-
return UIGraphicsImageRenderer(size: canvas, format: format).image {
17-
_ in draw(in: CGRect(origin: .zero, size: canvas))
18-
}
10+
/// Crops the image to ensure width and height do not exceed maxSize.
11+
/// Keeps original aspect ratio and crops excess equally from edges (center crop).
12+
func resized(toWidth width: CGFloat, isOpaque: Bool = true) -> UIImage? {
13+
let canvas = CGSize(
14+
width: width,
15+
height: CGFloat(ceil(width / size.width * size.height))
16+
)
17+
let format = imageRendererFormat
18+
format.opaque = isOpaque
19+
return UIGraphicsImageRenderer(size: canvas, format: format).image {
20+
_ in draw(in: CGRect(origin: .zero, size: canvas))
1921
}
22+
}
2023
}

0 commit comments

Comments
 (0)