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