1 package expo.modules.updates.launcher 2 3 import android.content.Context 4 import android.net.Uri 5 import android.util.Log 6 import expo.modules.updates.UpdatesConfiguration 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.launcher.Launcher.LauncherCallback 12 import expo.modules.updates.loader.EmbeddedLoader 13 import expo.modules.updates.loader.FileDownloader 14 import expo.modules.updates.loader.FileDownloader.AssetDownloadCallback 15 import expo.modules.updates.loader.LoaderFiles 16 import expo.modules.updates.manifest.EmbeddedManifest 17 import expo.modules.updates.manifest.ManifestMetadata 18 import expo.modules.updates.selectionpolicy.SelectionPolicy 19 import java.io.File 20 import java.util.* 21 22 /** 23 * Implementation of [Launcher] that uses the SQLite database and expo-updates file store as the 24 * source of updates. 25 * 26 * Uses the [SelectionPolicy] to choose an update from SQLite to launch, then ensures that the 27 * update is safe and ready to launch (i.e. all the assets that SQLite expects to be stored on disk 28 * are actually there). 29 * 30 * This class also includes failsafe code to attempt to re-download any assets unexpectedly missing 31 * from disk (since it isn't necessarily safe to just revert to an older update in this case). 32 * Distinct from the [Loader] classes, though, this class does *not* make any major modifications to 33 * the database; its role is mostly to read the database and ensure integrity with the file system. 34 * 35 * It's important that the update to launch is selected *before* any other checks, e.g. the above 36 * check for assets on disk. This is to preserve the invariant that no older update should ever be 37 * launched after a newer one has been launched. 38 */ 39 class DatabaseLauncher( 40 private val configuration: UpdatesConfiguration, 41 private val updatesDirectory: File?, 42 private val fileDownloader: FileDownloader, 43 private val selectionPolicy: SelectionPolicy 44 ) : Launcher { 45 private val loaderFiles: LoaderFiles = LoaderFiles() 46 override var launchedUpdate: UpdateEntity? = null 47 private set 48 override var launchAssetFile: String? = null 49 private set 50 override var bundleAssetName: String? = null 51 private set 52 override var localAssetFiles: MutableMap<AssetEntity, String>? = null 53 private set 54 override val isUsingEmbeddedAssets: Boolean 55 get() = localAssetFiles == null 56 57 private var assetsToDownload = 0 58 private var assetsToDownloadFinished = 0 59 private var launchAssetException: Exception? = null 60 private var callback: LauncherCallback? = null 61 62 @Synchronized launchnull63 fun launch(database: UpdatesDatabase, context: Context, callback: LauncherCallback?) { 64 if (this.callback != null) { 65 throw AssertionError("DatabaseLauncher has already started. Create a new instance in order to launch a new version.") 66 } 67 this.callback = callback 68 69 launchedUpdate = getLaunchableUpdate(database, context) 70 if (launchedUpdate == null) { 71 this.callback!!.onFailure(Exception("No launchable update was found. If this is a bare workflow app, make sure you have configured expo-updates correctly in android/app/build.gradle.")) 72 return 73 } 74 75 database.updateDao().markUpdateAccessed(launchedUpdate!!) 76 77 if (launchedUpdate!!.status == UpdateStatus.EMBEDDED) { 78 bundleAssetName = EmbeddedLoader.BARE_BUNDLE_FILENAME 79 if (localAssetFiles != null) { 80 throw AssertionError("mLocalAssetFiles should be null for embedded updates") 81 } 82 this.callback!!.onSuccess() 83 return 84 } else if (launchedUpdate!!.status == UpdateStatus.DEVELOPMENT) { 85 this.callback!!.onSuccess() 86 return 87 } 88 89 // verify that we have all assets on disk 90 // according to the database, we should, but something could have gone wrong on disk 91 val launchAsset = database.updateDao().loadLaunchAsset(launchedUpdate!!.id) 92 if (launchAsset.relativePath == null) { 93 throw AssertionError("Launch Asset relativePath should not be null") 94 } 95 96 val launchAssetFile = ensureAssetExists(launchAsset, database, context) 97 if (launchAssetFile != null) { 98 this.launchAssetFile = launchAssetFile.toString() 99 } 100 101 val assetEntities = database.assetDao().loadAssetsForUpdate(launchedUpdate!!.id) 102 103 localAssetFiles = mutableMapOf<AssetEntity, String>().apply { 104 for (asset in assetEntities) { 105 if (asset.id == launchAsset.id) { 106 // we took care of this one above 107 continue 108 } 109 val filename = asset.relativePath 110 if (filename != null) { 111 val assetFile = ensureAssetExists(asset, database, context) 112 if (assetFile != null) { 113 this[asset] = Uri.fromFile(assetFile).toString() 114 } 115 } 116 } 117 } 118 119 if (assetsToDownload == 0) { 120 if (this.launchAssetFile == null) { 121 this.callback!!.onFailure(Exception("mLaunchAssetFile was immediately null; this should never happen")) 122 } else { 123 this.callback!!.onSuccess() 124 } 125 } 126 } 127 getLaunchableUpdatenull128 fun getLaunchableUpdate(database: UpdatesDatabase, context: Context): UpdateEntity? { 129 val launchableUpdates = database.updateDao().loadLaunchableUpdatesForScope(configuration.scopeKey) 130 131 // We can only run an update marked as embedded if it's actually the update embedded in the 132 // current binary. We might have an older update from a previous binary still listed as 133 // "EMBEDDED" in the database so we need to do this check. 134 val embeddedUpdateManifest = EmbeddedManifest.get(context, configuration) 135 val filteredLaunchableUpdates = mutableListOf<UpdateEntity>() 136 for (update in launchableUpdates) { 137 if (update.status == UpdateStatus.EMBEDDED) { 138 if (embeddedUpdateManifest != null && embeddedUpdateManifest.updateEntity!!.id != update.id) { 139 continue 140 } 141 } 142 filteredLaunchableUpdates.add(update) 143 } 144 val manifestFilters = ManifestMetadata.getManifestFilters(database, configuration) 145 return selectionPolicy.selectUpdateToLaunch(filteredLaunchableUpdates, manifestFilters) 146 } 147 getReadyUpdateIdsnull148 fun getReadyUpdateIds(database: UpdatesDatabase): List<UUID> { 149 return database.updateDao().loadAllUpdateIdsWithStatus(UpdateStatus.READY) 150 } 151 ensureAssetExistsnull152 internal fun ensureAssetExists(asset: AssetEntity, database: UpdatesDatabase, context: Context): File? { 153 val assetFile = File(updatesDirectory, asset.relativePath) 154 var assetFileExists = assetFile.exists() 155 if (!assetFileExists) { 156 // something has gone wrong, we're missing this asset 157 // first we check to see if a copy is embedded in the binary 158 val embeddedUpdateManifest = EmbeddedManifest.get(context, configuration) 159 if (embeddedUpdateManifest != null) { 160 val embeddedAssets = embeddedUpdateManifest.assetEntityList 161 var matchingEmbeddedAsset: AssetEntity? = null 162 for (embeddedAsset in embeddedAssets) { 163 if (embeddedAsset.key != null && embeddedAsset.key == asset.key) { 164 matchingEmbeddedAsset = embeddedAsset 165 break 166 } 167 } 168 169 if (matchingEmbeddedAsset != null) { 170 try { 171 val hash = loaderFiles.copyAssetAndGetHash(matchingEmbeddedAsset, assetFile, context) 172 if (Arrays.equals(hash, asset.hash)) { 173 assetFileExists = true 174 } 175 } catch (e: Exception) { 176 // things are really not going our way... 177 Log.e(TAG, "Failed to copy matching embedded asset", e) 178 } 179 } 180 } 181 } 182 183 return if (!assetFileExists) { 184 // we still don't have the asset locally, so try downloading it remotely 185 assetsToDownload++ 186 fileDownloader.downloadAsset( 187 asset, 188 updatesDirectory, 189 configuration, 190 context, 191 object : AssetDownloadCallback { 192 override fun onFailure(e: Exception, assetEntity: AssetEntity) { 193 Log.e(TAG, "Failed to load asset from disk or network", e) 194 if (assetEntity.isLaunchAsset) { 195 launchAssetException = e 196 } 197 maybeFinish(assetEntity, null) 198 } 199 200 override fun onSuccess(assetEntity: AssetEntity, isNew: Boolean) { 201 database.assetDao().updateAsset(assetEntity) 202 val assetFileLocal = File(updatesDirectory, assetEntity.relativePath) 203 maybeFinish(assetEntity, if (assetFileLocal.exists()) assetFileLocal else null) 204 } 205 } 206 ) 207 null 208 } else { 209 assetFile 210 } 211 } 212 213 @Synchronized maybeFinishnull214 private fun maybeFinish(asset: AssetEntity, assetFile: File?) { 215 assetsToDownloadFinished++ 216 if (asset.isLaunchAsset) { 217 launchAssetFile = if (assetFile == null) { 218 Log.e(TAG, "Could not launch; failed to load update from disk or network") 219 null 220 } else { 221 assetFile.toString() 222 } 223 } else { 224 if (assetFile != null) { 225 localAssetFiles!![asset] = assetFile.toString() 226 } 227 } 228 if (assetsToDownloadFinished == assetsToDownload) { 229 if (launchAssetFile == null) { 230 if (launchAssetException == null) { 231 launchAssetException = Exception("Launcher mLaunchAssetFile is unexpectedly null") 232 } 233 callback!!.onFailure(launchAssetException!!) 234 } else { 235 callback!!.onSuccess() 236 } 237 } 238 } 239 240 companion object { 241 private val TAG = DatabaseLauncher::class.java.simpleName 242 } 243 } 244