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: Error? { 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: Error? = 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: Error? = 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: Error? = 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: Error? {
87     return (message != nil) ? UpdatesStateException(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: Error? {
97     return (message != nil) ? UpdatesStateException(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: Error? = 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: Error? = 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: Error? = 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: Error? = 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: Error? = 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: Error? = nil
147 }
148 
149 /**
150  The state machine context, with information that will be readable from JS.
151  */
152 internal struct UpdatesStateContext {
153   let isUpdateAvailable: Bool
154   let isUpdatePending: Bool
155   let isRollback: Bool
156   let isChecking: Bool
157   let isDownloading: Bool
158   let isRestarting: Bool
159   let latestManifest: [String: Any]?
160   let downloadedManifest: [String: Any]?
161   let checkError: Error?
162   let downloadError: Error?
163 
164   var json: [String: Any?] {
165     return [
166       "isUpdateAvailable": self.isUpdateAvailable,
167       "isUpdatePending": self.isUpdatePending,
168       "isRollback": self.isRollback,
169       "isChecking": self.isChecking,
170       "isDownloading": self.isDownloading,
171       "isRestarting": self.isRestarting,
172       "latestManifest": self.latestManifest,
173       "downloadedManifest": self.downloadedManifest,
174       "checkError": self.checkError,
175       "downloadError": self.downloadError
176     ] as [String: Any?]
177   }
178 }
179 
180 extension UpdatesStateContext {
181   init() {
182     self.isUpdateAvailable = false
183     self.isUpdatePending = false
184     self.isRollback = false
185     self.isChecking = false
186     self.isDownloading = false
187     self.isRestarting = false
188     self.latestManifest = nil
189     self.downloadedManifest = nil
190     self.checkError = nil
191     self.downloadError = nil
192   }
193 
194   // struct copy, lets you overwrite specific variables retaining the value of the rest
195   // using a closure to set the new values for the copy of the struct
196   func copy(build: (inout Builder) -> Void) -> UpdatesStateContext {
197     var builder = Builder(original: self)
198     build(&builder)
199     return builder.toContext()
200   }
201 
202   struct Builder {
203     var isUpdateAvailable: Bool = false
204     var isUpdatePending: Bool = false
205     var isRollback: Bool = false
206     var isChecking: Bool = false
207     var isDownloading: Bool = false
208     var isRestarting: Bool = false
209     var latestManifest: [String: Any]?
210     var downloadedManifest: [String: Any]?
211     var checkError: Error?
212     var downloadError: Error?
213 
214     fileprivate init(original: UpdatesStateContext) {
215       self.isUpdateAvailable = original.isUpdateAvailable
216       self.isUpdatePending = original.isUpdatePending
217       self.isRollback = original.isRollback
218       self.isChecking = original.isChecking
219       self.isDownloading = original.isDownloading
220       self.isRestarting = original.isRestarting
221       self.latestManifest = original.latestManifest
222       self.downloadedManifest = original.downloadedManifest
223       self.checkError = original.checkError
224       self.downloadError = original.downloadError
225     }
226 
227     fileprivate func toContext() -> UpdatesStateContext {
228       return UpdatesStateContext(
229         isUpdateAvailable: isUpdateAvailable,
230         isUpdatePending: isUpdatePending,
231         isRollback: isRollback,
232         isChecking: isChecking,
233         isDownloading: isDownloading,
234         isRestarting: isRestarting,
235         latestManifest: latestManifest,
236         downloadedManifest: downloadedManifest,
237         checkError: checkError,
238         downloadError: downloadError
239       )
240     }
241   }
242 }
243 
244 // MARK: - State machine class
245 
246 /**
247  The Updates state machine class. There should be only one instance of this class
248  in a production app, instantiated as a property of AppController.
249  */
250 internal class UpdatesStateMachine {
251   private let logger = UpdatesLogger()
252 
253   init(changeEventDelegate: (any UpdatesStateChangeDelegate)) {
254     self.changeEventDelegate = changeEventDelegate
255   }
256 
257   // MARK: - Public methods and properties
258 
259   /**
260    In production, this is the AppController instance.
261    */
262   private weak var changeEventDelegate: (any UpdatesStateChangeDelegate)?
263 
264   /**
265    The current state
266    */
267   internal var state: UpdatesStateValue = .idle
268 
269   /**
270    The context
271    */
272   internal var context: UpdatesStateContext = UpdatesStateContext()
273 
274   /**
275    Called after the app restarts (reloadAsync()) to reset the machine to its
276    starting state.
277    */
278   internal func reset() {
279     state = .idle
280     context = UpdatesStateContext()
281     logger.info(message: "Updates state is reset, state = \(state), context = \(context)")
282     sendChangeEventToJS()
283   }
284 
285   /**
286    Called by AppLoaderTask delegate methods in AppController during the initial
287    background check for updates, and called by checkForUpdateAsync(), fetchUpdateAsync(), and reloadAsync().
288    */
289   internal func processEvent(_ event: UpdatesStateEvent) {
290     // Execute state transition
291     if transition(event) {
292       // Only change context if transition succeeds
293       context = reducedContext(context, event)
294       logger.info(message: "Updates state change: state = \(state), event = \(event.type), context = \(context)")
295       // Send change event
296       sendChangeEventToJS(event)
297     }
298   }
299 
300   // MARK: - Private methods
301 
302   /**
303    Make sure the state transition is allowed, and then update the state.
304    */
305   private func transition(_ event: UpdatesStateEvent) -> Bool {
306     let allowedEvents: Set<UpdatesStateEventType> = UpdatesStateMachine.updatesStateAllowedEvents[state] ?? []
307     if !allowedEvents.contains(event.type) {
308       // Uncomment the line below to halt execution on invalid state transitions,
309       // very useful for testing
310       /*
311       assertionFailure("UpdatesState: invalid transition requested: state = \(state), event = \(event.type)")
312        */
313       return false
314     }
315     // Successful transition
316     state = UpdatesStateMachine.updatesStateTransitions[event.type] ?? .idle
317     return true
318   }
319 
320   /**
321    Given an allowed event and a context, return a new context with the changes
322    made by processing the event.
323    */
324   private func reducedContext(_ context: UpdatesStateContext, _ event: UpdatesStateEvent) -> UpdatesStateContext {
325     switch event.type {
326     case .check:
327       return context.copy {
328         $0.isChecking = true
329       }
330     case .checkCompleteUnavailable:
331       return context.copy {
332         $0.isChecking = false
333         $0.checkError = nil
334         $0.latestManifest = nil
335         $0.isUpdateAvailable = false
336         $0.isRollback = false
337       }
338     case .checkCompleteAvailable:
339       return context.copy {
340         $0.isChecking = false
341         $0.checkError = nil
342         $0.latestManifest = event.manifest
343         $0.isRollback = event.isRollback
344         $0.isUpdateAvailable = true
345       }
346     case .checkError:
347       return context.copy {
348         $0.isChecking = false
349         $0.checkError = event.error
350       }
351     case .download:
352       return context.copy {
353         $0.isDownloading = true
354       }
355     case .downloadComplete:
356       return context.copy {
357         $0.isDownloading = false
358         $0.downloadError = nil
359         $0.latestManifest = event.manifest ?? context.latestManifest
360         $0.downloadedManifest = event.manifest ?? context.downloadedManifest
361         $0.isUpdatePending = $0.downloadedManifest != nil
362         $0.isUpdateAvailable = event.manifest != nil || context.isUpdateAvailable
363       }
364     case .downloadError:
365       return context.copy {
366         $0.isDownloading = false
367         $0.downloadError = event.error
368       }
369     case .restart:
370       return context.copy {
371         $0.isRestarting = true
372       }
373     }
374   }
375 
376   /**
377    On each state change, all context properties are sent to JS
378    */
379   private func sendChangeEventToJS(_ event: UpdatesStateEvent? = nil) {
380     changeEventDelegate?.sendUpdateStateChangeEventToBridge(event?.type ?? .restart, body: [
381       "context": context.json
382     ])
383   }
384 
385   // MARK: - Static definitions of the state machine rules
386 
387   /**
388    For a particular machine state, only certain events may be processed.
389    If the machine receives an unexpected event, an assertion failure will occur
390    and the app will crash.
391    */
392   static let updatesStateAllowedEvents: [UpdatesStateValue: Set<UpdatesStateEventType>] = [
393     .idle: [.check, .download, .restart],
394     .checking: [.checkCompleteAvailable, .checkCompleteUnavailable, .checkError],
395     .downloading: [.downloadComplete, .downloadError],
396     .restarting: []
397   ]
398 
399   /**
400    For this state machine, each event has only one destination state that the
401    machine will transition to.
402    */
403   static let updatesStateTransitions: [UpdatesStateEventType: UpdatesStateValue] = [
404     .check: .checking,
405     .checkCompleteAvailable: .idle,
406     .checkCompleteUnavailable: .idle,
407     .checkError: .idle,
408     .download: .downloading,
409     .downloadComplete: .idle,
410     .downloadError: .idle,
411     .restart: .restarting
412   ]
413 }
414 
415 // swiftlint:enable no_grouping_extension
416 // swiftlint:enable type_name
417