1 package expo.modules.updates.loader
2 
3 import android.content.Context
4 import android.util.Log
5 import expo.modules.updates.UpdatesConfiguration
6 import expo.modules.updates.UpdatesUtils
7 import expo.modules.updates.db.UpdatesDatabase
8 import expo.modules.updates.db.entity.AssetEntity
9 import expo.modules.updates.db.entity.UpdateEntity
10 import expo.modules.updates.db.enums.UpdateStatus
11 import expo.modules.updates.loader.FileDownloader.AssetDownloadCallback
12 import expo.modules.updates.loader.FileDownloader.RemoteUpdateDownloadCallback
13 import expo.modules.updates.manifest.ManifestMetadata
14 import expo.modules.updates.manifest.UpdateManifest
15 import java.io.File
16 import java.util.*
17 
18 /**
19  * Abstract class responsible for loading an update, enumerating the assets required for
20  * it to launch, and loading them all onto disk and into SQLite.
21  *
22  * There are two sources from which an update can be loaded - a remote server given a URL, and the
23  * application package. These correspond to the two loader subclasses.
24  */
25 abstract class Loader protected constructor(
26   private val context: Context,
27   private val configuration: UpdatesConfiguration,
28   private val database: UpdatesDatabase,
29   private val updatesDirectory: File?,
30   private val loaderFiles: LoaderFiles
31 ) {
32   private var updateResponse: UpdateResponse? = null
33   private var updateEntity: UpdateEntity? = null
34   private var callback: LoaderCallback? = null
35   private var assetTotal = 0
36   private var erroredAssetList = mutableListOf<AssetEntity>()
37   private var skippedAssetList = mutableListOf<AssetEntity>()
38   private var existingAssetList = mutableListOf<AssetEntity>()
39   private var finishedAssetList = mutableListOf<AssetEntity>()
40 
41   data class LoaderResult(val updateEntity: UpdateEntity?, val updateDirective: UpdateDirective?)
42 
43   data class OnUpdateResponseLoadedResult(val shouldDownloadManifestIfPresentInResponse: Boolean)
44 
45   interface LoaderCallback {
onFailurenull46     fun onFailure(e: Exception)
47     fun onSuccess(loaderResult: LoaderResult)
48 
49     /**
50      * Called when an asset has either been successfully downloaded or failed to download.
51      *
52      * @param asset Entity representing the asset that was either just downloaded or failed
53      * @param successfulAssetCount The number of assets that have so far been loaded successfully
54      * (including any that were found to already exist on disk)
55      * @param failedAssetCount The number of assets that have so far failed to load
56      * @param totalAssetCount The total number of assets that comprise the update
57      */
58     fun onAssetLoaded(
59       asset: AssetEntity,
60       successfulAssetCount: Int,
61       failedAssetCount: Int,
62       totalAssetCount: Int
63     )
64 
65     /**
66      * Called when a response has been downloaded. The calling class should determine whether or not
67      * the RemoteLoader should continue to download the manifest in the manifest part of the update response,
68      * based on (for example) whether or not it already has the update downloaded locally.
69      *
70      * @param updateResponse Response downloaded by Loader
71      * @return true if Loader should download the manifest described in the manifest part of the update response,
72      * false if not.
73      */
74     fun onUpdateResponseLoaded(updateResponse: UpdateResponse): OnUpdateResponseLoadedResult
75   }
76 
77   protected abstract fun loadRemoteUpdate(
78     context: Context,
79     database: UpdatesDatabase,
80     configuration: UpdatesConfiguration,
81     callback: RemoteUpdateDownloadCallback
82   )
83 
84   protected abstract fun loadAsset(
85     context: Context,
86     assetEntity: AssetEntity,
87     updatesDirectory: File?,
88     configuration: UpdatesConfiguration,
89     callback: AssetDownloadCallback
90   )
91 
92   protected abstract fun shouldSkipAsset(assetEntity: AssetEntity): Boolean
93 
94   // lifecycle methods for class
95   fun start(callback: LoaderCallback) {
96     if (this.callback != null) {
97       callback.onFailure(Exception("RemoteLoader has already started. Create a new instance in order to load multiple URLs in parallel."))
98       return
99     }
100     this.callback = callback
101 
102     loadRemoteUpdate(
103       context, database, configuration,
104       object : RemoteUpdateDownloadCallback {
105         override fun onFailure(message: String, e: Exception) {
106           finishWithError(message, e)
107         }
108 
109         override fun onSuccess(updateResponse: UpdateResponse) {
110           this@Loader.updateResponse = updateResponse
111           val updateManifest = updateResponse.manifestUpdateResponsePart?.updateManifest
112           val onUpdateResponseLoadedResult = this@Loader.callback!!.onUpdateResponseLoaded(updateResponse)
113           if (updateManifest !== null && onUpdateResponseLoadedResult.shouldDownloadManifestIfPresentInResponse) {
114             // if onUpdateResponseLoaded returns true that is a sign that the delegate wants the update manifest
115             // to be processed/downloaded, and therefore the updateManifest needs to exist
116             processUpdateManifest(updateManifest)
117           } else {
118             updateEntity = null
119             finishWithSuccess()
120           }
121         }
122       }
123     )
124   }
125 
resetnull126   private fun reset() {
127     updateResponse = null
128     updateEntity = null
129     callback = null
130     assetTotal = 0
131     erroredAssetList = mutableListOf()
132     skippedAssetList = mutableListOf()
133     existingAssetList = mutableListOf()
134     finishedAssetList = mutableListOf()
135   }
136 
finishWithSuccessnull137   private fun finishWithSuccess() {
138     if (callback == null) {
139       Log.e(
140         TAG,
141         this.javaClass.simpleName + " tried to finish but it already finished or was never initialized."
142       )
143       return
144     }
145 
146     // store the header data even if only a message was included in the response
147     updateResponse!!.responseHeaderData?.let {
148       ManifestMetadata.saveMetadata(it, database, configuration)
149     }
150 
151     val updateDirective = updateResponse!!.directiveUpdateResponsePart?.updateDirective
152 
153     callback!!.onSuccess(
154       LoaderResult(
155         updateEntity = this.updateEntity,
156         updateDirective = updateDirective
157       )
158     )
159     reset()
160   }
161 
finishWithErrornull162   private fun finishWithError(message: String, e: Exception) {
163     Log.e(TAG, message, e)
164     if (callback == null) {
165       Log.e(
166         TAG,
167         this.javaClass.simpleName + " tried to finish but it already finished or was never initialized."
168       )
169       return
170     }
171     callback!!.onFailure(e)
172     reset()
173   }
174 
175   // private helper methods
processUpdateManifestnull176   private fun processUpdateManifest(updateManifest: UpdateManifest) {
177     if (updateManifest.isDevelopmentMode) {
178       // insert into database but don't try to load any assets;
179       // the RN runtime will take care of that and we don't want to cache anything
180       val updateEntity = updateManifest.updateEntity
181       database.updateDao().insertUpdate(updateEntity!!)
182       database.updateDao().markUpdateFinished(updateEntity)
183       finishWithSuccess()
184       return
185     }
186 
187     val newUpdateEntity = updateManifest.updateEntity
188     val existingUpdateEntity = database.updateDao().loadUpdateWithId(
189       newUpdateEntity!!.id
190     )
191 
192     // if something has gone wrong on the server and we have two updates with the same id
193     // but different scope keys, we should try to launch something rather than show a cryptic
194     // error to the user.
195     if (existingUpdateEntity != null && existingUpdateEntity.scopeKey != newUpdateEntity.scopeKey) {
196       database.updateDao().setUpdateScopeKey(existingUpdateEntity, newUpdateEntity.scopeKey)
197       Log.e(
198         TAG,
199         "Loaded an update with the same ID but a different scopeKey than one we already have on disk. This is a server error. Overwriting the scopeKey and loading the existing update."
200       )
201     }
202 
203     if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) {
204       // hooray, we already have this update downloaded and ready to go!
205       updateEntity = existingUpdateEntity
206       finishWithSuccess()
207     } else {
208       if (existingUpdateEntity == null) {
209         // no update already exists with this ID, so we need to insert it and download everything.
210         updateEntity = newUpdateEntity
211         database.updateDao().insertUpdate(updateEntity!!)
212       } else {
213         // we've already partially downloaded the update, so we should use the existing entity.
214         // however, it's not ready, so we should try to download all the assets again.
215         updateEntity = existingUpdateEntity
216       }
217       downloadAllAssets(updateManifest.assetEntityList)
218     }
219   }
220 
221   private enum class AssetLoadResult {
222     FINISHED, ALREADY_EXISTS, ERRORED, SKIPPED
223   }
224 
downloadAllAssetsnull225   private fun downloadAllAssets(assetList: List<AssetEntity>) {
226     assetTotal = assetList.size
227     for (assetEntityCur in assetList) {
228       var assetEntity = assetEntityCur
229       if (shouldSkipAsset(assetEntity)) {
230         handleAssetDownloadCompleted(assetEntity, AssetLoadResult.SKIPPED)
231         continue
232       }
233 
234       val matchingDbEntry = database.assetDao().loadAssetWithKey(assetEntity.key)
235       if (matchingDbEntry != null) {
236         // merge all fields not stored in the database onto matchingDbEntry,
237         // in case we need them later on in this class
238         database.assetDao().mergeAndUpdateAsset(matchingDbEntry, assetEntity)
239         assetEntity = matchingDbEntry
240       }
241 
242       // if we already have a local copy of this asset, don't try to download it again!
243       if (assetEntity.relativePath != null && loaderFiles.fileExists(
244           File(
245               updatesDirectory,
246               assetEntity.relativePath
247             )
248         )
249       ) {
250         handleAssetDownloadCompleted(assetEntity, AssetLoadResult.ALREADY_EXISTS)
251         continue
252       }
253 
254       loadAsset(
255         context, assetEntity, updatesDirectory, configuration,
256         object : AssetDownloadCallback {
257           override fun onFailure(e: Exception, assetEntity: AssetEntity) {
258             val identifier = if (assetEntity.hash != null) "hash " + UpdatesUtils.bytesToHex(
259               assetEntity.hash!!
260             ) else "key " + assetEntity.key
261             Log.e(TAG, "Failed to download asset with $identifier", e)
262             handleAssetDownloadCompleted(assetEntity, AssetLoadResult.ERRORED)
263           }
264 
265           override fun onSuccess(assetEntity: AssetEntity, isNew: Boolean) {
266             handleAssetDownloadCompleted(
267               assetEntity,
268               if (isNew) AssetLoadResult.FINISHED else AssetLoadResult.ALREADY_EXISTS
269             )
270           }
271         }
272       )
273     }
274   }
275 
276   @Synchronized
handleAssetDownloadCompletednull277   private fun handleAssetDownloadCompleted(assetEntity: AssetEntity, result: AssetLoadResult) {
278     when (result) {
279       AssetLoadResult.FINISHED -> finishedAssetList.add(assetEntity)
280       AssetLoadResult.ALREADY_EXISTS -> existingAssetList.add(assetEntity)
281       AssetLoadResult.ERRORED -> erroredAssetList.add(assetEntity)
282       AssetLoadResult.SKIPPED -> skippedAssetList.add(assetEntity)
283       else -> throw AssertionError("Missing implementation for AssetLoadResult value")
284     }
285 
286     callback!!.onAssetLoaded(
287       assetEntity,
288       finishedAssetList.size + existingAssetList.size,
289       erroredAssetList.size,
290       assetTotal
291     )
292 
293     if (finishedAssetList.size + erroredAssetList.size + existingAssetList.size + skippedAssetList.size == assetTotal) {
294       try {
295         for (asset in existingAssetList) {
296           val existingAssetFound = database.assetDao()
297             .addExistingAssetToUpdate(updateEntity!!, asset, asset.isLaunchAsset)
298           if (!existingAssetFound) {
299             // the database and filesystem have gotten out of sync
300             // do our best to create a new entry for this file even though it already existed on disk
301             // TODO: we should probably get rid of this assumption that if an asset exists on disk with the same filename, it's the same asset
302             var hash: ByteArray? = null
303             try {
304               hash = UpdatesUtils.sha256(File(updatesDirectory, asset.relativePath))
305             } catch (e: Exception) {
306             }
307             asset.downloadTime = Date()
308             asset.hash = hash
309             finishedAssetList.add(asset)
310           }
311         }
312 
313         database.assetDao().insertAssets(finishedAssetList, updateEntity!!)
314 
315         if (erroredAssetList.size == 0) {
316           database.updateDao().markUpdateFinished(updateEntity!!, skippedAssetList.size != 0)
317         }
318       } catch (e: Exception) {
319         finishWithError("Error while adding new update to database", e)
320         return
321       }
322 
323       if (erroredAssetList.size > 0) {
324         finishWithError("Failed to load all assets", Exception("Failed to load all assets"))
325       } else {
326         finishWithSuccess()
327       }
328     }
329   }
330 
331   companion object {
332     private val TAG = Loader::class.java.simpleName
333   }
334 }
335