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