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
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 is RollBackToEmbeddedUpdateDirective:
362           self.isUpToDate = false
363 
364           if let swiftDelegate = self.swiftDelegate {
365             self.delegateQueue.async {
366               swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.rollBackToEmbedded)
367             }
368           }
369 
370           if let delegate = self.delegate {
371             self.delegateQueue.async {
372               delegate.appLoaderTask(self, didStartLoadingUpdate: nil)
373             }
374           }
375           return true
376         default:
377           NSException(name: .internalInconsistencyException, reason: "Unhandled update directive type").raise()
378           return false
379         }
380       }
381 
382       guard let update = updateResponse.manifestUpdateResponsePart?.updateManifest else {
383         // No response, so no update available
384         self.isUpToDate = true
385         if let swiftDelegate = self.swiftDelegate {
386           self.delegateQueue.async {
387             swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable)
388           }
389         }
390         return false
391       }
392 
393       if self.selectionPolicy.shouldLoadNewUpdate(
394         update,
395         withLaunchedUpdate: self.candidateLauncher?.launchedUpdate,
396         filters: updateResponse.responseHeaderData?.manifestFilters
397       ) {
398         // got a response, and it is new so should be downloaded
399         self.isUpToDate = false
400         if let swiftDelegate = self.swiftDelegate {
401           self.delegateQueue.async {
402             swiftDelegate.appLoaderTask(
403               self,
404               didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.updateAvailable(
405                 manifest: update.manifest.rawManifestJSON()
406               )
407             )
408           }
409         }
410 
411         if let delegate = self.delegate {
412           self.delegateQueue.async {
413             delegate.appLoaderTask(self, didStartLoadingUpdate: update)
414           }
415         }
416         return true
417       } else {
418         // got a response, but we already have it
419         self.isUpToDate = true
420         if let swiftDelegate = self.swiftDelegate {
421           self.delegateQueue.async {
422             swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.noUpdateAvailable)
423           }
424         }
425         return false
426       }
427     } asset: { asset, successfulAssetCount, failedAssetCount, totalAssetCount in
428       if let swiftDelegate = self.swiftDelegate {
429         self.delegateQueue.async {
430           swiftDelegate.appLoaderTask(
431             self,
432             didLoadAsset: asset,
433             successfulAssetCount: successfulAssetCount,
434             failedAssetCount: failedAssetCount,
435             totalAssetCount: totalAssetCount
436           )
437         }
438       }
439     } success: { updateResponse in
440       completion(nil, updateResponse)
441     } error: { error in
442       if let swiftDelegate = self.swiftDelegate {
443         self.delegateQueue.async {
444           swiftDelegate.appLoaderTask(self, didFinishCheckingForRemoteUpdateWithRemoteCheckResult: RemoteCheckResult.error(error: error))
445         }
446       }
447       completion(error, nil)
448     }
449   }
450 
451   private func handleRemoteUpdateResponseLoaded(_ updateResponse: UpdateResponse?, error: Error?) {
452     // If the app has not yet been launched (because the timer is still running),
453     // create a new launcher so that we can launch with the newly downloaded update.
454     // Otherwise, we've already launched. Send an event to the notify JS of the new update.
455 
456     loaderTaskQueue.async {
457       self.stopTimer()
458 
459       RemoteAppLoader.processSuccessLoaderResult(
460         config: self.config,
461         database: self.database,
462         selectionPolicy: self.selectionPolicy,
463         launchedUpdate: self.candidateLauncher?.launchedUpdate,
464         directory: self.directory,
465         loaderTaskQueue: self.loaderTaskQueue,
466         updateResponse: updateResponse,
467         priorError: error
468       ) { updateToLaunch, error, _ in
469         self.launchUpdate(updateToLaunch, error: error)
470       }
471     }
472   }
473 
474   private func launchUpdate(_ updateBeingLaunched: Update?, error: Error?) {
475     if let updateBeingLaunched = updateBeingLaunched {
476       if !self.hasLaunched {
477         let newLauncher = AppLauncherWithDatabase(
478           config: self.config,
479           database: self.database,
480           directory: self.directory,
481           completionQueue: self.loaderTaskQueue
482         )
483         newLauncher.launchUpdate(withSelectionPolicy: self.selectionPolicy) { error, success in
484           if success {
485             if !self.hasLaunched {
486               self.candidateLauncher = newLauncher
487               self.isReadyToLaunch = true
488               self.isUpToDate = true
489               self.finish(withError: nil)
490             }
491           } else {
492             self.finish(withError: error)
493             NSLog("Downloaded update but failed to relaunch: %@", error?.localizedDescription ?? "")
494           }
495           self.isRunning = false
496           self.runReaper()
497         }
498       } else {
499         self.didFinishBackgroundUpdate(withStatus: .updateAvailable, update: updateBeingLaunched, error: nil)
500         self.isRunning = false
501         self.runReaper()
502       }
503     } else {
504       // there's no update, so signal we're ready to launch
505       self.finish(withError: error)
506       if let error = error {
507         self.didFinishBackgroundUpdate(withStatus: .error, update: nil, error: error)
508       } else {
509         self.didFinishBackgroundUpdate(withStatus: .noUpdateAvailable, update: nil, error: nil)
510       }
511       self.isRunning = false
512       self.runReaper()
513     }
514   }
515 
516   private func didFinishBackgroundUpdate(withStatus status: BackgroundUpdateStatus, update: Update?, error: Error?) {
517     delegate.let { it in
518       delegateQueue.async {
519         it.appLoaderTask(self, didFinishBackgroundUpdateWithStatus: status, update: update, error: error)
520       }
521     }
522   }
523 }
524 
525 // swiftlint:enable closure_body_length
526 // swiftlint:enable force_unwrapping
527 // swiftlint:enable superfluous_else
528 // swiftlint:enable cyclomatic_complexity
529