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