<lambda>null1 package expo.modules.updates
2 
3 import android.content.Context
4 import expo.modules.updates.UpdatesConfiguration.CheckAutomaticallyConfiguration
5 import expo.modules.updates.db.entity.AssetEntity
6 import android.os.AsyncTask
7 import android.net.ConnectivityManager
8 import android.os.Build
9 import android.util.Base64
10 import android.util.Log
11 import com.facebook.react.ReactNativeHost
12 import com.facebook.react.bridge.Arguments
13 import com.facebook.react.bridge.ReactContext
14 import com.facebook.react.bridge.WritableMap
15 import com.facebook.react.modules.core.DeviceEventManagerModule
16 import expo.modules.updates.logging.UpdatesErrorCode
17 import expo.modules.updates.logging.UpdatesLogger
18 import org.apache.commons.io.FileUtils
19 import org.json.JSONArray
20 import org.json.JSONObject
21 import java.io.*
22 import java.lang.ClassCastException
23 import java.lang.Exception
24 import java.lang.IllegalArgumentException
25 import java.lang.ref.WeakReference
26 import java.security.DigestInputStream
27 import java.security.MessageDigest
28 import java.security.NoSuchAlgorithmException
29 import java.text.DateFormat
30 import java.text.ParseException
31 import java.text.SimpleDateFormat
32 import java.util.*
33 import kotlin.experimental.and
34 
35 /**
36  * Miscellaneous helper functions that are used by multiple classes in the library.
37  */
38 object UpdatesUtils {
39   private val TAG = UpdatesUtils::class.java.simpleName
40 
41   private const val UPDATES_DIRECTORY_NAME = ".expo-internal"
42 
43   @Throws(Exception::class)
44   fun getMapFromJSONString(stringifiedJSON: String): Map<String, String> {
45     val jsonObject = JSONObject(stringifiedJSON)
46     val keys = jsonObject.keys()
47     val newMap = mutableMapOf<String, String>()
48     while (keys.hasNext()) {
49       val key = keys.next()
50       newMap[key] = try {
51         jsonObject[key] as String
52       } catch (e: ClassCastException) {
53         throw Exception("The values in the JSON object must be strings")
54       }
55     }
56     return newMap
57   }
58 
59   @Throws(Exception::class)
60   fun getStringListFromJSONString(stringifiedJSON: String): List<String> {
61     val jsonArray = JSONArray(stringifiedJSON)
62     return List(jsonArray.length()) { index -> jsonArray.getString(index) }
63   }
64 
65   @Throws(Exception::class)
66   fun getOrCreateUpdatesDirectory(context: Context): File {
67     val updatesDirectory = File(context.filesDir, UPDATES_DIRECTORY_NAME)
68     val exists = updatesDirectory.exists()
69     if (exists) {
70       if (updatesDirectory.isFile) {
71         throw Exception("File already exists at the location of the Updates Directory: $updatesDirectory ; aborting")
72       }
73     } else {
74       if (!updatesDirectory.mkdir()) {
75         throw Exception("Failed to create Updates Directory: mkdir() returned false")
76       }
77     }
78     return updatesDirectory
79   }
80 
81   @Throws(NoSuchAlgorithmException::class, UnsupportedEncodingException::class)
82   fun sha256(string: String): String {
83     return try {
84       val md = MessageDigest.getInstance("SHA-256")
85       val data = string.toByteArray(charset("UTF-8"))
86       md.update(data, 0, data.size)
87       val sha1hash = md.digest()
88       bytesToHex(sha1hash)
89     } catch (e: NoSuchAlgorithmException) {
90       Log.e(TAG, "Failed to checksum string via SHA-256", e)
91       throw e
92     } catch (e: UnsupportedEncodingException) {
93       Log.e(TAG, "Failed to checksum string via SHA-256", e)
94       throw e
95     }
96   }
97 
98   @Throws(NoSuchAlgorithmException::class, IOException::class)
99   fun sha256(file: File): ByteArray {
100     try {
101       FileInputStream(file).use { inputStream ->
102         DigestInputStream(
103           inputStream,
104           MessageDigest.getInstance("SHA-256")
105         ).use { digestInputStream ->
106           val md = digestInputStream.messageDigest
107           return md.digest()
108         }
109       }
110     } catch (e: NoSuchAlgorithmException) {
111       Log.e(TAG, "Failed to checksum file via SHA-256: $file", e)
112       throw e
113     } catch (e: IOException) {
114       Log.e(TAG, "Failed to checksum file via SHA-256: $file", e)
115       throw e
116     }
117   }
118 
119   @Throws(NoSuchAlgorithmException::class, IOException::class)
120   fun verifySHA256AndWriteToFile(inputStream: InputStream, destination: File, expectedBase64URLEncodedHash: String?): ByteArray {
121     DigestInputStream(inputStream, MessageDigest.getInstance("SHA-256")).use { digestInputStream ->
122       // write file atomically by writing it to a temporary path and then renaming
123       // this protects us against partially written files if the process is interrupted
124       val tmpFile = File(destination.absolutePath + ".tmp")
125       FileUtils.copyInputStreamToFile(digestInputStream, tmpFile)
126 
127       // this message digest must be read after the input stream has been consumed in order to get the hash correctly
128       val md = digestInputStream.messageDigest
129       val hash = md.digest()
130       // base64url - https://datatracker.ietf.org/doc/html/rfc4648#section-5
131       val hashBase64String = Base64.encodeToString(hash, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
132       if (expectedBase64URLEncodedHash != null && expectedBase64URLEncodedHash != hashBase64String) {
133         throw IOException("File download was successful but base64url-encoded SHA-256 did not match expected; expected: $expectedBase64URLEncodedHash; actual: $hashBase64String")
134       }
135 
136       // only rename after the hash has been verified
137       if (!tmpFile.renameTo(destination)) {
138         throw IOException("File download was successful, but failed to move from temporary to permanent location " + destination.absolutePath)
139       }
140 
141       return hash
142     }
143   }
144 
145   fun createFilenameForAsset(asset: AssetEntity): String {
146     var fileExtension: String? = ""
147     if (asset.type != null) {
148       fileExtension = if (asset.type!!.startsWith(".")) asset.type else "." + asset.type
149     }
150     return if (asset.key == null) {
151       // create a filename that's unlikely to collide with any other asset
152       "asset-" + Date().time + "-" + Random().nextInt() + fileExtension
153     } else asset.key + fileExtension
154   }
155 
156   fun sendEventToReactNative(
157     reactNativeHost: WeakReference<ReactNativeHost>?,
158     logger: UpdatesLogger,
159     eventName: String,
160     eventType: String,
161     params: WritableMap?
162   ) {
163     val host = reactNativeHost?.get()
164     if (host != null) {
165       AsyncTask.execute {
166         try {
167           var reactContext: ReactContext? = null
168           // in case we're trying to send an event before the reactContext has been initialized
169           // continue to retry for 5000ms
170           for (i in 0..4) {
171             // Calling host.reactInstanceManager has a side effect of creating a new
172             // reactInstanceManager if there isn't already one. We want to avoid this so we check
173             // if it has an instance first.
174             if (host.hasInstance()) {
175               reactContext = host.reactInstanceManager.currentReactContext
176               if (reactContext != null) {
177                 break
178               }
179             }
180             Thread.sleep(1000)
181           }
182           if (reactContext != null) {
183             val emitter = reactContext.getJSModule(
184               DeviceEventManagerModule.RCTDeviceEventEmitter::class.java
185             )
186             if (emitter != null) {
187               var eventParams = params
188               if (eventParams == null) {
189                 eventParams = Arguments.createMap()
190               }
191               eventParams!!.putString("type", eventType)
192               logger.info("Emitted event: name = $eventName, type = $eventType")
193               emitter.emit(eventName, eventParams)
194               return@execute
195             }
196           }
197           logger.error("Could not emit $eventName $eventType event; no event emitter was found.", UpdatesErrorCode.JSRuntimeError)
198         } catch (e: Exception) {
199           logger.error("Could not emit $eventName $eventType event; no react context was found.", UpdatesErrorCode.JSRuntimeError)
200         }
201       }
202     } else {
203       logger.error(
204         "Could not emit $eventType event; UpdatesController was not initialized with an instance of ReactApplication.",
205         UpdatesErrorCode.Unknown
206       )
207     }
208   }
209 
210   fun shouldCheckForUpdateOnLaunch(
211     updatesConfiguration: UpdatesConfiguration,
212     context: Context
213   ): Boolean {
214     if (updatesConfiguration.updateUrl == null) {
215       return false
216     }
217     return when (updatesConfiguration.checkOnLaunch) {
218       CheckAutomaticallyConfiguration.NEVER -> false
219       // check will happen later on if there's an error
220       CheckAutomaticallyConfiguration.ERROR_RECOVERY_ONLY -> false
221       CheckAutomaticallyConfiguration.WIFI_ONLY -> {
222         val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
223         if (cm == null) {
224           Log.e(
225             TAG,
226             "Could not determine active network connection is metered; not checking for updates"
227           )
228           return false
229         }
230         !cm.isActiveNetworkMetered
231       }
232       CheckAutomaticallyConfiguration.ALWAYS -> true
233     }
234   }
235 
236   fun getRuntimeVersion(updatesConfiguration: UpdatesConfiguration): String {
237     val runtimeVersion = updatesConfiguration.runtimeVersion
238     val sdkVersion = updatesConfiguration.sdkVersion
239     return if (runtimeVersion != null && runtimeVersion.isNotEmpty()) {
240       runtimeVersion
241     } else if (sdkVersion != null && sdkVersion.isNotEmpty()) {
242       sdkVersion
243     } else {
244       // various places in the code assume that we have a nonnull runtimeVersion, so if the developer
245       // hasn't configured either runtimeVersion or sdkVersion, we'll use a dummy value of "1" but warn
246       // the developer in JS that they need to configure one of these values
247       "1"
248     }
249   }
250 
251   // https://stackoverflow.com/questions/9655181/how-to-convert-a-byte-array-to-a-hex-string-in-java
252   private val HEX_ARRAY = "0123456789ABCDEF".toCharArray()
253   fun bytesToHex(bytes: ByteArray): String {
254     val hexChars = CharArray(bytes.size * 2)
255     for (j in bytes.indices) {
256       val v = (bytes[j] and 0xFF.toByte()).toInt()
257       hexChars[j * 2] = HEX_ARRAY[v ushr 4]
258       hexChars[j * 2 + 1] = HEX_ARRAY[v and 0x0F]
259     }
260     return String(hexChars)
261   }
262 
263   @Throws(ParseException::class)
264   fun parseDateString(dateString: String): Date {
265     return try {
266       val formatter: DateFormat = when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
267         true -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'X'", Locale.US)
268         false -> SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply {
269           timeZone = TimeZone.getTimeZone("GMT")
270         }
271       }
272       formatter.parse(dateString) as Date
273     } catch (e: ParseException) {
274       Log.e(TAG, "Failed to parse date string on first try: $dateString", e)
275       // some old Android versions don't support the 'X' character in SimpleDateFormat, so try without this
276       val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
277       formatter.timeZone = TimeZone.getTimeZone("GMT")
278       // throw if this fails too
279       formatter.parse(dateString) as Date
280     } catch (e: IllegalArgumentException) {
281       Log.e(TAG, "Failed to parse date string on first try: $dateString", e)
282       val formatter: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
283       formatter.timeZone = TimeZone.getTimeZone("GMT")
284       formatter.parse(dateString) as Date
285     }
286   }
287 }
288