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