1 //  Copyright © 2023 650 Industries. All rights reserved.
2 
3 // swiftlint:disable no_grouping_extension
4 // swiftlint:disable type_name
5 
6 import Foundation
7 
8 /**
9  Protocol with a method for sending state change events to JS.
10  In production, this will be implemented by the AppController.sharedInstance.
11  */
12 internal protocol UpdatesStateChangeDelegate: AnyObject {
13   func sendUpdateStateChangeEventToBridge(_ eventType: UpdatesStateEventType, body: [String: Any?])
14 }
15 
16 // MARK: - Enums
17 
18 /**
19  All the possible states the machine can take.
20  */
21 internal enum UpdatesStateValue: String {
22   case idle
23   case checking
24   case downloading
25   case restarting
26 }
27 
28 /**
29  All the possible types of events that can be sent to the machine. Each event
30  will cause the machine to transition to a new state.
31  */
32 internal enum UpdatesStateEventType: String {
33   case check
34   case checkCompleteUnavailable
35   case checkCompleteAvailable
36   case checkError
37   case download
38   case downloadComplete
39   case downloadError
40   case restart
41 }
42 
43 // MARK: - Data structures
44 
45 /**
46  Protocol representing an event that can be sent to the machine, and
47  structs representing the different event types
48  */
49 internal protocol UpdatesStateEvent {
50   var type: UpdatesStateEventType { get }
51   var manifest: [String: Any]? { get }
52   var message: String? { get }
53   var isRollback: Bool { get }
54   var error: [String: String]? { get }
55 }
56 
57 internal struct UpdatesStateEventCheck: UpdatesStateEvent {
58   let type: UpdatesStateEventType = .check
59   let manifest: [String: Any]? = nil
60   let message: String? = nil
61   let isRollback: Bool = false
62   let error: [String: String]? = nil
63 }
64 
65 internal struct UpdatesStateEventDownload: UpdatesStateEvent {
66   let type: UpdatesStateEventType = .download
67   let manifest: [String: Any]? = nil
68   let message: String? = nil
69   let isRollback: Bool = false
70   let error: [String: String]? = nil
71 }
72 
73 internal struct UpdatesStateEventRestart: UpdatesStateEvent {
74   let type: UpdatesStateEventType = .restart
75   let manifest: [String: Any]? = nil
76   let message: String? = nil
77   let isRollback: Bool = false
78   let error: [String: String]? = nil
79 }
80 
81 internal struct UpdatesStateEventCheckError: UpdatesStateEvent {
82   let type: UpdatesStateEventType = .checkError
83   let manifest: [String: Any]? = nil
84   let message: String?
85   let isRollback: Bool = false
86   var error: [String: String]? {
87     return (message != nil) ? ["message": message ?? ""] : nil
88   }
89 }
90 
91 internal struct UpdatesStateEventDownloadError: UpdatesStateEvent {
92   let type: UpdatesStateEventType = .downloadError
93   let manifest: [String: Any]? = nil
94   let message: String?
95   let isRollback: Bool = false
96   var error: [String: String]? {
97     return (message != nil) ? ["message": message ?? ""] : nil
98   }
99 }
100 
101 internal struct UpdatesStateEventCheckCompleteWithUpdate: UpdatesStateEvent {
102   let type: UpdatesStateEventType = .checkCompleteAvailable
103   let manifest: [String: Any]?
104   let message: String? = nil
105   let isRollback: Bool = false
106   let error: [String: String]? = nil
107 }
108 
109 internal struct UpdatesStateEventCheckCompleteWithRollback: UpdatesStateEvent {
110   let type: UpdatesStateEventType = .checkCompleteAvailable
111   let manifest: [String: Any]? = nil
112   let message: String? = nil
113   let isRollback: Bool = true
114   let error: [String: String]? = nil
115 }
116 
117 internal struct UpdatesStateEventCheckComplete: UpdatesStateEvent {
118   let type: UpdatesStateEventType = .checkCompleteUnavailable
119   let manifest: [String: Any]? = nil
120   let message: String? = nil
121   let isRollback: Bool = false
122   let error: [String: String]? = nil
123 }
124 
125 internal struct UpdatesStateEventDownloadCompleteWithUpdate: UpdatesStateEvent {
126   let type: UpdatesStateEventType = .downloadComplete
127   let manifest: [String: Any]?
128   let message: String? = nil
129   let isRollback: Bool = false
130   let error: [String: String]? = nil
131 }
132 
133 internal struct UpdatesStateEventDownloadCompleteWithRollback: UpdatesStateEvent {
134   let type: UpdatesStateEventType = .downloadComplete
135   let manifest: [String: Any]? = nil
136   let message: String? = nil
137   let isRollback: Bool = true
138   let error: [String: String]? = nil
139 }
140 
141 internal struct UpdatesStateEventDownloadComplete: UpdatesStateEvent {
142   let type: UpdatesStateEventType = .downloadComplete
143   let manifest: [String: Any]? = nil
144   let message: String? = nil
145   let isRollback: Bool = false
146   let error: [String: String]? = nil
147 }
148 
149 /**
150  Date formatter for the last check times sent in JS events
151  */
152 let iso8601DateFormatter = ISO8601DateFormatter()
153 
154 /**
155  The state machine context, with information that will be readable from JS.
156  */
157 internal struct UpdatesStateContext {
158   let isUpdateAvailable: Bool
159   let isUpdatePending: Bool
160   let isRollback: Bool
161   let isChecking: Bool
162   let isDownloading: Bool
163   let isRestarting: Bool
164   let latestManifest: [String: Any]?
165   let downloadedManifest: [String: Any]?
166   let checkError: [String: String]?
167   let downloadError: [String: String]?
168   let lastCheckForUpdateTime: Date?
169 
170   private var lastCheckForUpdateTimeDateString: String? {
171     guard let lastCheckForUpdateTime = lastCheckForUpdateTime else {
172       return nil
173     }
174     return iso8601DateFormatter.string(from: lastCheckForUpdateTime)
175   }
176 
177   var json: [String: Any?] {
178     return [
179       "isUpdateAvailable": self.isUpdateAvailable,
180       "isUpdatePending": self.isUpdatePending,
181       "isRollback": self.isRollback,
182       "isChecking": self.isChecking,
183       "isDownloading": self.isDownloading,
184       "isRestarting": self.isRestarting,
185       "latestManifest": self.latestManifest,
186       "downloadedManifest": self.downloadedManifest,
187       "checkError": self.checkError,
188       "downloadError": self.downloadError,
189       "lastCheckForUpdateTimeString": lastCheckForUpdateTimeDateString
190     ] as [String: Any?]
191   }
192 }
193 
194 extension UpdatesStateContext {
195   init() {
196     self.isUpdateAvailable = false
197     self.isUpdatePending = false
198     self.isRollback = false
199     self.isChecking = false
200     self.isDownloading = false
201     self.isRestarting = false
202     self.latestManifest = nil
203     self.downloadedManifest = nil
204     self.checkError = nil
205     self.downloadError = nil
206     self.lastCheckForUpdateTime = nil
207   }
208 
209   // struct copy, lets you overwrite specific variables retaining the value of the rest
210   // using a closure to set the new values for the copy of the struct
211   func copy(build: (inout Builder) -> Void) -> UpdatesStateContext {
212     var builder = Builder(original: self)
213     build(&builder)
214     return builder.toContext()
215   }
216 
217   struct Builder {
218     var isUpdateAvailable: Bool = false
219     var isUpdatePending: Bool = false
220     var isRollback: Bool = false
221     var isChecking: Bool = false
222     var isDownloading: Bool = false
223     var isRestarting: Bool = false
224     var latestManifest: [String: Any]?
225     var downloadedManifest: [String: Any]?
226     var checkError: [String: String]?
227     var downloadError: [String: String]?
228     var lastCheckForUpdateTime: Date?
229 
230     fileprivate init(original: UpdatesStateContext) {
231       self.isUpdateAvailable = original.isUpdateAvailable
232       self.isUpdatePending = original.isUpdatePending
233       self.isRollback = original.isRollback
234       self.isChecking = original.isChecking
235       self.isDownloading = original.isDownloading
236       self.isRestarting = original.isRestarting
237       self.latestManifest = original.latestManifest
238       self.downloadedManifest = original.downloadedManifest
239       self.checkError = original.checkError
240       self.downloadError = original.downloadError
241       self.lastCheckForUpdateTime = original.lastCheckForUpdateTime
242     }
243 
244     fileprivate func toContext() -> UpdatesStateContext {
245       return UpdatesStateContext(
246         isUpdateAvailable: isUpdateAvailable,
247         isUpdatePending: isUpdatePending,
248         isRollback: isRollback,
249         isChecking: isChecking,
250         isDownloading: isDownloading,
251         isRestarting: isRestarting,
252         latestManifest: latestManifest,
253         downloadedManifest: downloadedManifest,
254         checkError: checkError,
255         downloadError: downloadError,
256         lastCheckForUpdateTime: lastCheckForUpdateTime
257       )
258     }
259   }
260 }
261 
262 // MARK: - State machine class
263 
264 /**
265  The Updates state machine class. There should be only one instance of this class
266  in a production app, instantiated as a property of AppController.
267  */
268 internal class UpdatesStateMachine {
269   private let logger = UpdatesLogger()
270 
271   init(changeEventDelegate: (any UpdatesStateChangeDelegate)) {
272     self.changeEventDelegate = changeEventDelegate
273   }
274 
275   // MARK: - Public methods and properties
276 
277   /**
278    In production, this is the AppController instance.
279    */
280   private weak var changeEventDelegate: (any UpdatesStateChangeDelegate)?
281 
282   /**
283    The current state
284    */
285   internal var state: UpdatesStateValue = .idle
286 
287   /**
288    The context
289    */
290   internal var context: UpdatesStateContext = UpdatesStateContext()
291 
292   /**
293    Called after the app restarts (reloadAsync()) to reset the machine to its
294    starting state.
295    */
296   internal func reset() {
297     state = .idle
298     context = UpdatesStateContext()
299     logger.info(message: "Updates state is reset, state = \(state), context = \(context)")
300     sendChangeEventToJS()
301   }
302 
303   /**
304    Called by AppLoaderTask delegate methods in AppController during the initial
305    background check for updates, and called by checkForUpdateAsync(), fetchUpdateAsync(), and reloadAsync().
306    */
307   internal func processEvent(_ event: UpdatesStateEvent) {
308     // Execute state transition
309     if transition(event) {
310       // Only change context if transition succeeds
311       context = reducedContext(context, event)
312       logger.info(message: "Updates state change: state = \(state), event = \(event.type), context = \(context)")
313       // Send change event
314       sendChangeEventToJS(event)
315     }
316   }
317 
318   // MARK: - Private methods
319 
320   /**
321    Make sure the state transition is allowed, and then update the state.
322    */
323   private func transition(_ event: UpdatesStateEvent) -> Bool {
324     let allowedEvents: Set<UpdatesStateEventType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? []
325     if !allowedEvents.contains(event.type) {
326       // Uncomment the line below to halt execution on invalid state transitions,
327       // very useful for testing
328       /*
329       assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)")
330        */
331       return false
332     }
333     // Successful transition
334     state = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle
335     return true
336   }
337 
338   /**
339    Given an allowed event and a context, return a new context with the changes
340    made by processing the event.
341    */
342   private func reducedContext(_ context: UpdatesStateContext, _ event: UpdatesStateEvent) -> UpdatesStateContext {
343     switch event.type {
344     case .check:
345       return context.copy {
346         $0.isChecking = true
347       }
348     case .checkCompleteUnavailable:
349       return context.copy {
350         $0.isChecking = false
351         $0.checkError = nil
352         $0.latestManifest = nil
353         $0.isUpdateAvailable = false
354         $0.isRollback = false
355         $0.lastCheckForUpdateTime = Date()
356       }
357     case .checkCompleteAvailable:
358       return context.copy {
359         $0.isChecking = false
360         $0.checkError = nil
361         $0.latestManifest = event.manifest
362         $0.isRollback = event.isRollback
363         $0.isUpdateAvailable = true
364         $0.lastCheckForUpdateTime = Date()
365       }
366     case .checkError:
367       return context.copy {
368         $0.isChecking = false
369         $0.checkError = event.error
370         $0.lastCheckForUpdateTime = Date()
371       }
372     case .download:
373       return context.copy {
374         $0.isDownloading = true
375       }
376     case .downloadComplete:
377       return context.copy {
378         $0.isDownloading = false
379         $0.downloadError = nil
380         $0.latestManifest = event.manifest ?? context.latestManifest
381         $0.downloadedManifest = event.manifest ?? context.downloadedManifest
382         $0.isUpdatePending = $0.downloadedManifest != nil
383         $0.isUpdateAvailable = event.manifest != nil || context.isUpdateAvailable
384       }
385     case .downloadError:
386       return context.copy {
387         $0.isDownloading = false
388         $0.downloadError = event.error
389       }
390     case .restart:
391       return context.copy {
392         $0.isRestarting = true
393       }
394     }
395   }
396 
397   /**
398    On each state change, all context properties are sent to JS
399    */
400   private func sendChangeEventToJS(_ event: UpdatesStateEvent? = nil) {
401     changeEventDelegate?.sendUpdateStateChangeEventToBridge(event?.type ?? .restart, body: [
402       "context": context.json
403     ])
404   }
405 
406   // MARK: - Static definitions of the state machine rules
407 
408   /**
409    For a particular machine state, only certain events may be processed.
410    If the machine receives an unexpected event, an assertion failure will occur
411    and the app will crash.
412    */
413   static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEventType>] = [
414     .idle: [.check, .download, .restart],
415     .checking: [.checkCompleteAvailable, .checkCompleteUnavailable, .checkError],
416     .downloading: [.downloadComplete, .downloadError],
417     .restarting: []
418   ]
419 
420   /**
421    For this state machine, each event has only one destination state that the
422    machine will transition to.
423    */
424   static let updatesStateTransitions: [UpdatesStateEventType: UpdatesStateValue] = [
425     .check: .checking,
426     .checkCompleteAvailable: .idle,
427     .checkCompleteUnavailable: .idle,
428     .checkError: .idle,
429     .download: .downloading,
430     .downloadComplete: .idle,
431     .downloadError: .idle,
432     .restart: .restarting
433   ]
434 }
435 
436 // swiftlint:enable no_grouping_extension
437 // swiftlint:enable type_name
438