1 //  Copyright © 2019 650 Industries. All rights reserved.
2 
3 // swiftlint:disable closure_body_length
4 // swiftlint:disable superfluous_else
5 // swiftlint:disable cyclomatic_complexity
6 
7 // this class uses a ton of implicit non-null properties based on method call order. not worth changing to appease lint
8 // swiftlint:disable force_unwrapping
9 
10 import Foundation
11 
12 @objc(EXUpdatesAppLoaderTaskDelegate)
13 public protocol AppLoaderTaskDelegate: AnyObject {
14   /**
15    * This method gives the delegate a backdoor option to ignore the cached update and force
16    * a remote load if it decides the cached update is not runnable. Returning NO from this
17    * callback will force a remote load, overriding the timeout and configuration settings for
18    * whether or not to check for a remote update. Returning YES from this callback will make
19    * AppLoaderTask proceed as usual.
20    */
21   func appLoaderTask(_: AppLoaderTask, didLoadCachedUpdate update: Update) -> Bool
22   func appLoaderTask(_: AppLoaderTask, didStartLoadingUpdate update: Update?)
23   func appLoaderTask(_: AppLoaderTask, didFinishWithLauncher launcher: AppLauncher, isUpToDate: Bool)
24   func appLoaderTask(_: AppLoaderTask, didFinishWithError error: Error)
25   func appLoaderTask(
26     _: AppLoaderTask,
27     didFinishBackgroundUpdateWithStatus status: BackgroundUpdateStatus,
28     update: Update?,
29     error: Error?
30   )
31 }
32 
33 public enum RemoteCheckResult {
34   case noUpdateAvailable
35   case updateAvailable(manifest: [String: Any])
36   case rollBackToEmbedded(commitTime: Date)
37   case error(error: Error)
38 }
39 
40 public protocol AppLoaderTaskSwiftDelegate: AnyObject {
41   func appLoaderTaskDidStartCheckingForRemoteUpdate(_: AppLoaderTask)
42   func appLoaderTask(_: AppLoaderTask, didFinishCheckingForRemoteUpdateWithRemoteCheckResult remoteCheckResult: RemoteCheckResult)
43   func appLoaderTask(_: AppLoaderTask, didLoadAsset asset: UpdateAsset, successfulAssetCount: Int, failedAssetCount: Int, totalAssetCount: Int)
44 }
45 
46 @objc(EXUpdatesBackgroundUpdateStatus)
47 public enum BackgroundUpdateStatus: Int {
48   case error = 0
49   case noUpdateAvailable = 1
50   case updateAvailable = 2
51 }
52 
53 /**
54  * Controlling class that handles the complex logic that needs to happen each time the app is cold
55  * booted. From a high level, this class does the following:
56  *
57  * - Immediately starts an instance of EmbeddedAppLoader to load the embedded update into
58  *   SQLite. This does nothing if SQLite already has the embedded update or a newer one, but we have
59  *   to do this on each cold boot, as we have no way of knowing if a new build was just installed
60  *   (which could have a new embedded update).
61  * - If the app is configured for automatic update downloads (most apps), starts a timer based on
62  *   the `launchWaitMs` value in UpdatesConfig.
63  * - Again if the app is configured for automatic update downloads, starts an instance of
64  *   RemoteAppLoader to check for and download a new update if there is one.
65  * - Once the download succeeds, fails, or the timer runs out (whichever happens first), creates an
66  *   instance of AppLauncherWithDatabase and signals that the app is ready to be launched
67  *   with the newest update available locally at that time (which may not be the newest update if
68  *   the download is still in progress).
69  * - If the download succeeds or fails after this point, fires a callback which causes an event to
70  *   be sent to JS.
71  */
72 @objc(EXUpdatesAppLoaderTask)
73 @objcMembers
74 public final class AppLoaderTask: NSObject {
75   private static let ErrorDomain = "EXUpdatesAppLoaderTask"
76 
77   public weak var delegate: AppLoaderTaskDelegate?
78   public weak var swiftDelegate: AppLoaderTaskSwiftDelegate?
79 
80   private let config: UpdatesConfig
81   private let database: UpdatesDatabase
82   private let directory: URL
83   private let selectionPolicy: SelectionPolicy
84   private let delegateQueue: DispatchQueue
85 
86   private var candidateLauncher: AppLauncher?
87   private var finalizedLauncher: AppLauncher?
88   private var embeddedAppLoader: EmbeddedAppLoader?
89   private var remoteAppLoader: RemoteAppLoader?
90   private let logger: UpdatesLogger
91 
92   private var timer: Timer?
93   public private(set) var isRunning: Bool
94   private var isReadyToLaunch: Bool
95   private var isTimerFinished: Bool
96   private var hasLaunched: Bool
97   private var isUpToDate: Bool
98   private let loaderTaskQueue: DispatchQueue
99 
100   public required init(
101     withConfig config: UpdatesConfig,
102     database: UpdatesDatabase,
103     directory: URL,
104     selectionPolicy: SelectionPolicy,
105     delegateQueue: DispatchQueue
106   ) {
107     self.config = config
108     self.database = database
109     self.directory = directory
110     self.selectionPolicy = selectionPolicy
111     self.isRunning = false
112     self.isReadyToLaunch = false
113     self.isTimerFinished = false
114     self.hasLaunched = false
115     self.isUpToDate = false
116     self.delegateQueue = delegateQueue
117     self.loaderTaskQueue = DispatchQueue(label: "expo.loader.LoaderTaskQueue")
118     self.logger = UpdatesLogger()
119   }
120 
121   public func start() {
122     guard config.isEnabled else {
123       // swiftlint:disable:next line_length
124       let errorMessage = "AppLoaderTask was passed a configuration object with updates disabled. You should load updates from an embedded source rather than calling AppLoaderTask, or enable updates in the configuration."
125       logger.error(message: errorMessage, code: .updateFailedToLoad)
126       delegateQueue.async {
127         self.delegate?.appLoaderTask(
128           self,
129           didFinishWithError: NSError(
130             domain: AppLoaderTask.ErrorDomain,
131             code: 1030,
132             userInfo: [NSLocalizedDescriptionKey: errorMessage]
133           )
134         )
135       }
136       return
137     }
138 
139     guard config.updateUrl != nil else {
140       // swiftlint:disable:next line_length
141       let errorMessage = "AppLoaderTask was passed a configuration object with a null URL. You must pass a nonnull URL in order to use AppLoaderTask to load updates."
142       logger.error(message: errorMessage, code: .updateFailedToLoad)
143       delegateQueue.async {
144         self.delegate?.appLoaderTask(
145           self,
146           didFinishWithError: NSError(
147             domain: AppLoaderTask.ErrorDomain,
148             code: 1030,
149             userInfo: [NSLocalizedDescriptionKey: errorMessage]
150           )
151         )
152       }
153       return
154     }
155 
156     isRunning = true
157 
158     var shouldCheckForUpdate = UpdatesUtils.shouldCheckForUpdate(withConfig: config)
159     let launchWaitMs = config.launchWaitMs
160     if launchWaitMs == 0 || !shouldCheckForUpdate {
161       isTimerFinished = true
162     } else {
163       let fireDate = Date(timeIntervalSinceNow: Double(launchWaitMs) / 1000)
164       timer = Timer(fireAt: fireDate, interval: 0, target: self, selector: #selector(timerDidFire), userInfo: nil, repeats: false)
165       RunLoop.main.add(timer!, forMode: .default)
166     }
167 
168     loadEmbeddedUpdate {
169       self.launch { error, success in
170         if !success {
171           if !shouldCheckForUpdate {
172             self.finish(withError: error)
173           }
174           self.logger.error(
175             message: "Failed to launch embedded or launchable update: \(error?.localizedDescription ?? "")",
176             code: .updateFailedToLoad
177           )
178         } else {
179           if let delegate = self.delegate,
180             !delegate.appLoaderTask(self, didLoadCachedUpdate: self.candidateLauncher!.launchedUpdate!) {
181             // ignore timer and other settings and force launch a remote update.
182             self.candidateLauncher = nil
183             self.stopTimer()
184             shouldCheckForUpdate = true
185           } else {
186             self.isReadyToLaunch = true
187             self.maybeFinish()
188           }
189         }
190 
191         if shouldCheckForUpdate {
192           self.loadRemoteUpdate { remoteError, remoteUpdate in
193             self.handleRemoteUpdateResponseLoaded(remoteUpdate, error: remoteError)
194           }
195         } else {
196           self.isRunning = false
197           self.runReaper()
198         }
199       }
200     }
201   }
202 
203   private func finish(withError error: Error?) {
204     dispatchPrecondition(condition: .onQueue(loaderTaskQueue))
205 
206     if hasLaunched {
207       // we've already fired once, don't do it again
208       return
209     }
210 
211     hasLaunched = true
212     finalizedLauncher = candidateLauncher
213 
214     if let delegate = delegate {
215       delegateQueue.async {
216         if self.isReadyToLaunch &&
217           (self.finalizedLauncher!.launchAssetUrl != nil || self.finalizedLauncher!.launchedUpdate!.status == .StatusDevelopment) {
218           delegate.appLoaderTask(self, didFinishWithLauncher: self.finalizedLauncher!, isUpToDate: self.isUpToDate)
219         } else {
220           delegate.appLoaderTask(
221             self,
222             didFinishWithError: error ?? NSError(
223               domain: AppLoaderTask.ErrorDomain,
224               code: 1031,
225               userInfo: [
226                 NSLocalizedDescriptionKey: "AppLoaderTask encountered an unexpected error and could not launch an update."
227               ]
228             )
229           )
230         }
231       }
232     }
233 
234     stopTimer()
235   }
236 
237   private func maybeFinish() {
238     guard isTimerFinished && isReadyToLaunch else {
239       // too early, bail out
240       return
241     }
242     finish(withError: nil)
243   }
244 
245   func timerDidFire() {
246     loaderTaskQueue.async {
247       self.isTimerFinished = true
248       self.maybeFinish()
249     }
250   }
251 
252   private func stopTimer() {
253     timer.let { it in
254       it.invalidate()
255       timer = nil
256     }
257     isTimerFinished = true
258   }
259 
260   private func runReaper() {
261     if let launchedUpdate = finalizedLauncher?.launchedUpdate {
262       UpdatesReaper.reapUnusedUpdates(
263         withConfig: config,
264         database: database,
265         directory: directory,
266         selectionPolicy: selectionPolicy,
267         launchedUpdate: launchedUpdate
268       )
269     }
270   }
271 
272   private func loadEmbeddedUpdate(withCompletion completion: @escaping () -> Void) {
273     AppLauncherWithDatabase.launchableUpdate(
274       withConfig: config,
275       database: database,
276       selectionPolicy: selectionPolicy,
277       completionQueue: loaderTaskQueue
278     ) { error, launchableUpdate in
279       self.database.databaseQueue.async {
280         var manifestFiltersError: Error?
281         var manifestFilters: [String: Any]?
282         do {
283           manifestFilters = try self.database.manifestFilters(withScopeKey: self.config.scopeKey!)
284         } catch {
285           manifestFiltersError = error
286         }
287 
288         self.loaderTaskQueue.async {
289           if manifestFiltersError != nil {
290             completion()
291             return
292           }
293 
294           if self.config.hasEmbeddedUpdate && self.selectionPolicy.shouldLoadNewUpdate(
295             EmbeddedAppLoader.embeddedManifest(withConfig: self.config, database: self.database),
296             withLaunchedUpdate: launchableUpdate,
297             filters: manifestFilters
298           ) {
299             // launchedUpdate is nil because we don't yet have one, and it doesn't matter as we won't
300             // be sending an HTTP request from EmbeddedAppLoader
301             self.embeddedAppLoader = EmbeddedAppLoader(
302               config: self.config,
303               database: self.database,
304               directory: self.directory,
305               launchedUpdate: nil,
306               completionQueue: self.loaderTaskQueue
307             )
308             self.embeddedAppLoader!.loadUpdateResponseFromEmbeddedManifest(
309               withCallback: { _ in
310                 // we already checked using selection policy, so we don't need to check again
311                 return true
312               }, asset: { _, _, _, _ in
313                 // do nothing for now
314               }, success: { _ in
315                 completion()
316               }, error: { _ in
317                 completion()
318               }
319             )
320           } else {
321             completion()
322           }
323         }
324       }
325     }
326   }
327 
328   private func launch(withCompletion completion: @escaping (_ error: Error?, _ success: Bool) -> Void) {
329     let launcher = AppLauncherWithDatabase(config: config, database: database, directory: directory, completionQueue: loaderTaskQueue)
330     candidateLauncher = launcher
331     launcher.launchUpdate(withSelectionPolicy: selectionPolicy, completion: completion)
332   }
333 
334   private func loadRemoteUpdate(withCompletion completion: @escaping (_ remoteError: Error?, _ updateResponse: UpdateResponse?) -> Void) {
335     remoteAppLoader = RemoteAppLoader(
336       config: config,
337       database: database,
338       directory: directory,
339       launchedUpdate: candidateLauncher?.launchedUpdate,
340       completionQueue: loaderTaskQueue
341     )
342 
343     if let swiftDelegate = self.swiftDelegate {
344       self.delegateQueue.async {
345         swiftDelegate.appLoaderTaskDidStartCheckingForRemoteUpdate(self)
346       }
347     }
348     remoteAppLoader!.loadUpdate(
349       fromURL: config.updateUrl!
350     ) { updateResponse in
351       if let updateDirective = updateResponse.directiveUpdateResponsePart?.updateDirective {
352         switch updateDirective {
353         case is NoUpdateAvailableUpdateDirective:
354           self.isUpToDate = true
355           if let swiftDelegate = self.swiftDelegate {
356             self.delegateQueue.async {
357               swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable)
358             }
359           }
360           return false
361         case let rollBackUpdateDirective as RollBackToEmbeddedUpdateDirective:
362           self.isUpToDate = false
363 
364           if let swiftDelegate = self.swiftDelegate {
365             self.delegateQueue.async {
366               swiftDelegate.appLoaderTask(
367                 self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.rollBackToEmbedded(
368                   commitTime: RollBackToEmbeddedUpdateDirective.rollbackCommitTime(rollBackUpdateDirective)
369                 )
370               )
371             }
372           }
373 
374           if let delegate = self.delegate {
375             self.delegateQueue.async {
376               delegate.appLoaderTask(self, didStartLoadingUpdate: nil)
377             }
378           }
379           return true
380         default:
381           NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise()
382           return false
383         }
384       }
385 
386       guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else {
387         // No response, so no update available
388         self.isUpToDate = true
389         if let swiftDelegate = self.swiftDelegate {
390           self.delegateQueue.async {
391             swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable)
392           }
393         }
394         return false
395       }
396 
397       if self.selectionPolicy.shouldLoadNewUpdate(
398         update,
399         withLaunchedUpdate: self.candidateLauncher?.launchedUpdate,
400         filters: updateResponse.responseHeaderData?.manifestFilters
401       ) {
402         // got a response, and it is new so should be downloaded
403         self.isUpToDate = false
404         if let swiftDelegate = self.swiftDelegate {
405           self.delegateQueue.async {
406             swiftDelegate.appLoaderTask(
407               self,
408               didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.updateAvailable(
409                 manifest: update.manifest.rawManifestJSON()
410               )
411             )
412           }
413         }
414 
415         if let delegate = self.delegate {
416           self.delegateQueue.async {
417             delegate.appLoaderTask(self, didStartLoadingUpdate: update)
418           }
419         }
420         return true
421       } else {
422         // got a response, but we already have it
423         self.isUpToDate = true
424         if let swiftDelegate = self.swiftDelegate {
425           self.delegateQueue.async {
426             swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable)
427           }
428         }
429         return false
430       }
431     } asset: { asset, successfulAssetCount, failedAssetCount, totalAssetCount in
432       if let swiftDelegate = self.swiftDelegate {
433         self.delegateQueue.async {
434           swiftDelegate.appLoaderTask(
435             self,
436             didLoadAsset: asset,
437             successfulAssetCount: successfulAssetCount,
438             failedAssetCount: failedAssetCount,
439             totalAssetCount: totalAssetCount
440           )
441         }
442       }
443     } success: { updateResponse in
444       completion(nil, updateResponse)
445     } error: { error in
446       if let swiftDelegate = self.swiftDelegate {
447         self.delegateQueue.async {
448           swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.error(error: error))
449         }
450       }
451       completion(error, nil)
452     }
453   }
454 
455   private func handleRemoteUpdateResponseLoaded(_ updateResponse: UpdateResponse?, error: Error?) {
456     // If the app has not yet been launched (because the timer is still running),
457     // create a new launcher so that we can launch with the newly downloaded update.
458     // Otherwise, we've already launched. Send an event to the notify JS of the new update.
459 
460     loaderTaskQueue.async {
461       self.stopTimer()
462 
463       RemoteAppLoader.processSuccessLoaderResult(
464         config: self.config,
465         database: self.database,
466         selectionPolicy: self.selectionPolicy,
467         launchedUpdate: self.candidateLauncher?.launchedUpdate,
468         directory: self.directory,
469         loaderTaskQueue: self.loaderTaskQueue,
470         updateResponse: updateResponse,
471         priorError: error
472       ) { updateToLaunch, error, _ in
473         self.launchUpdate(updateToLaunch, error: error)
474       }
475     }
476   }
477 
478   private func launchUpdate(_ updateBeingLaunched: Update?, error: Error?) {
479     if let updateBeingLaunched = updateBeingLaunched {
480       if !self.hasLaunched {
481         let newLauncher = AppLauncherWithDatabase(
482           config: self.config,
483           database: self.database,
484           directory: self.directory,
485           completionQueue: self.loaderTaskQueue
486         )
487         newLauncher.launchUpdate(withSelectionPolicy: self.selectionPolicy) { error, success in
488           if success {
489             if !self.hasLaunched {
490               self.candidateLauncher = newLauncher
491               self.isReadyToLaunch = true
492               self.isUpToDate = true
493               self.finish(withError: nil)
494             }
495           } else {
496             self.finish(withError: error)
497             NSLog("Downloaded update but failed to relaunch: %@", error?.localizedDescription ?? "")
498           }
499           self.isRunning = false
500           self.runReaper()
501         }
502       } else {
503         self.didFinishBackgroundUpdate(withStatus: .updateAvailable, update: updateBeingLaunched, error: nil)
504         self.isRunning = false
505         self.runReaper()
506       }
507     } else {
508       // there's no update, so signal we're ready to launch
509       self.finish(withError: error)
510       if let error = error {
511         self.didFinishBackgroundUpdate(withStatus: .error, update: nil, error: error)
512       } else {
513         self.didFinishBackgroundUpdate(withStatus: .noUpdateAvailable, update: nil, error: nil)
514       }
515       self.isRunning = false
516       self.runReaper()
517     }
518   }
519 
520   private func didFinishBackgroundUpdate(withStatus status: BackgroundUpdateStatus, update: Update?, error: Error?) {
521     delegate.let { it in
522       delegateQueue.async {
523         it.appLoaderTask(self, didFinishBackgroundUpdateWithStatus: status, update: update, error: error)
524       }
525     }
526   }
527 }
528 
529 // swiftlint:enable closure_body_length
530 // swiftlint:enable force_unwrapping
531 // swiftlint:enable superfluous_else
532 // swiftlint:enable cyclomatic_complexity
533