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