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