Skip to content

Commit c0633e6

Browse files
authored
Workaround iOS issue calling widget timelines several times (#3414)
<!-- Thank you for submitting a Pull Request and helping to improve Home Assistant. Please complete the following sections to help the processing and review of your changes. Please do not delete anything from this template. --> ## Summary <!-- Provide a brief summary of the changes you have made and most importantly what they aim to achieve --> There is a long term bug in iOS which calls widget timelines several times even though once would be enough to render the widget. Since apparently Apple is not giving attention to that and out custom widgets make data calls, this PRs ads a small cache to prevent unecessary data calls. ## Screenshots <!-- If this is a user-facing change not in the frontend, please include screenshots in light and dark mode. --> ## Link to pull request in Documentation repository <!-- Pull requests that add, change or remove functionality must have a corresponding pull request in the Companion App Documentation repository (https://github.com/home-assistant/companion.home-assistant). Please add the number of this pull request after the "#" --> Documentation: home-assistant/companion.home-assistant# ## Any other notes <!-- If there is any other information of note, like if this Pull Request is part of a bigger change, please include it here. -->
1 parent c5d331e commit c0633e6

File tree

6 files changed

+81
-11
lines changed

6 files changed

+81
-11
lines changed

Sources/App/Scenes/WebViewSceneDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ final class WebViewSceneDelegate: NSObject, UIWindowSceneDelegate {
127127
})
128128
Current.appDatabaseUpdater.update()
129129
Current.panelsUpdater.update()
130+
131+
let widgetsCacheFile = AppConstants.widgetsCacheURL
132+
133+
// Clean up widgets cache file
134+
do {
135+
try FileManager.default.removeItem(at: widgetsCacheFile)
136+
} catch {
137+
Current.Log.error("Failed to remove widgets cache file: \(error)")
138+
}
130139
}
131140

132141
func windowScene(

Sources/Extensions/Widgets/Custom/AppIntents/CustomWidgetToggleAppIntent.swift

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,6 @@ struct CustomWidgetToggleAppIntent: AppIntent {
4747
}
4848
}
4949
_ = try await ResetAllCustomWidgetConfirmationAppIntent().perform()
50-
51-
/* Since several entities when toggled may not report the correct state right away
52-
This is a workaround to refresh the widget a little later
53-
In theory through push notification we could always ask the widget to update through
54-
and automation/blueprint etc, but it's currently not reliable https://developer.apple.com/forums/thread/773852 */
55-
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
56-
WidgetCenter.shared.reloadTimelines(ofKind: WidgetsKind.custom.rawValue)
57-
}
5850
return .result()
5951
}
6052
}

Sources/Extensions/Widgets/Custom/AppIntents/UpdateWidgetItemConfirmationStateAppIntent.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ struct UpdateWidgetItemConfirmationStateAppIntent: AppIntent {
2626

2727
if var widget = try CustomWidget.widgets()?.first(where: { $0.id == widgetId }),
2828
let magicItem = widget.items.first(where: { $0.serverUniqueId == serverUniqueId }) {
29-
widget.itemsStates[magicItem.serverUniqueId] = .pendingConfirmation
29+
widget.itemsStates = [magicItem.serverUniqueId: .pendingConfirmation]
3030
do {
3131
try await Current.database.write { [widget] db in
3232
try widget.update(db)

Sources/Extensions/Widgets/Custom/WidgetCustomTimelineProvider.swift

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,18 @@ struct WidgetCustomEntry: TimelineEntry {
1111
var showLastUpdateTime: Bool
1212
var showStates: Bool
1313

14-
struct ItemState {
14+
struct ItemState: Codable {
1515
let value: String
1616
let domainState: Domain.State?
1717
}
1818
}
1919

20+
struct WidgetCustomItemStatesCache: Codable {
21+
let widgetId: String
22+
let cacheCreatedDate: Date
23+
let states: [MagicItem: WidgetCustomEntry.ItemState]
24+
}
25+
2026
@available(iOS 17, *)
2127
struct WidgetCustomTimelineProvider: AppIntentTimelineProvider {
2228
typealias Entry = WidgetCustomEntry
@@ -121,6 +127,16 @@ struct WidgetCustomTimelineProvider: AppIntentTimelineProvider {
121127
return [:]
122128
}
123129

130+
/* Cache states in local json
131+
Necessary because there is a long term bug in widgets which triggers a reload of the timeline provider
132+
several times instead of just once */
133+
if let cache = getStatesCache(widgetId: widget.id), cache.cacheCreatedDate.timeIntervalSinceNow > -1 {
134+
Current.Log.verbose("Widget custom states cache is still valid, returning cached states")
135+
return cache.states
136+
}
137+
138+
Current.Log.verbose("Widget custom has no valid cache, fetching states")
139+
124140
let items = widget.items.filter {
125141
// No state needed for those domains
126142
![.script, .scene, .inputButton].contains($0.domain)
@@ -151,8 +167,41 @@ struct WidgetCustomTimelineProvider: AppIntentTimelineProvider {
151167
}
152168
}
153169

170+
/* Cache states in local json
171+
Necessary because there is a long term bug in widgets which triggers a reload of the timeline provider
172+
several times instead of just once */
173+
do {
174+
let cache = WidgetCustomItemStatesCache(
175+
widgetId: widget.id,
176+
cacheCreatedDate: Date(),
177+
states: states
178+
)
179+
let fileURL = AppConstants.widgetCachedStates(widgetId: widget.id)
180+
let encodedStates = try JSONEncoder().encode(cache)
181+
try encodedStates.write(to: fileURL)
182+
Current.Log
183+
.verbose("JSON saved successfully for widget custom cached states, file URL: \(fileURL.absoluteString)")
184+
} catch {
185+
Current.Log
186+
.error("Failed to cache states in WidgetCustomTimelineProvider, error: \(error.localizedDescription)")
187+
}
188+
154189
return states
155190
}
191+
192+
private func getStatesCache(widgetId: String) -> WidgetCustomItemStatesCache? {
193+
let fileURL = AppConstants.widgetCachedStates(widgetId: widgetId)
194+
do {
195+
let data = try Data(contentsOf: fileURL)
196+
return try JSONDecoder().decode(WidgetCustomItemStatesCache.self, from: data)
197+
} catch {
198+
Current.Log
199+
.error(
200+
"Failed to load states cache in WidgetCustomTimelineProvider, error: \(error.localizedDescription)"
201+
)
202+
return nil
203+
}
204+
}
156205
}
157206

158207
enum WidgetCustomConstants {

Sources/Shared/Domain/Domain.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public enum Domain: String, CaseIterable {
1717
case person
1818
// TODO: Map more domains
1919

20-
public enum State: String {
20+
public enum State: String, Codable {
2121
case locked
2222
case unlocked
2323
case jammed

Sources/Shared/Environment/AppConstants.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,26 @@ public enum AppConstants {
110110
return eventsURL
111111
}
112112

113+
public static var widgetsCacheURL: URL = {
114+
let fileManager = FileManager.default
115+
let directoryURL = Self.AppGroupContainer.appendingPathComponent("caches/widgets", isDirectory: true)
116+
return directoryURL
117+
}()
118+
119+
public static func widgetCachedStates(widgetId: String) -> URL {
120+
let fileManager = FileManager.default
121+
let directoryURL = Self.widgetsCacheURL
122+
if !fileManager.fileExists(atPath: directoryURL.path) {
123+
do {
124+
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true)
125+
} catch {
126+
Current.Log.error("Failed to create Client Events file")
127+
}
128+
}
129+
let eventsURL = directoryURL.appendingPathComponent("/widgetId-\(widgetId).json")
130+
return eventsURL
131+
}
132+
113133
public static var watchMagicItemsInfo: URL {
114134
let fileManager = FileManager.default
115135
let directoryURL = Self.AppGroupContainer.appendingPathComponent("caches", isDirectory: true)

0 commit comments

Comments
 (0)