package expo.modules.asset import android.content.Context import android.net.Uri import android.util.Log import expo.modules.kotlin.AppContext import expo.modules.kotlin.exception.CodedException import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.services.FilePermissionService import kotlinx.coroutines.async import java.io.File import java.io.InputStream import java.net.URI import java.security.MessageDigest internal class UnableToDownloadAssetException(url: String, cause: Throwable? = null) : CodedException("Unable to download asset from url: $url", cause) internal class FailedToDownloadAssetException(url: String) : CodedException("Failed to download asset from url: $url") class AssetModule : Module() { private val context: Context get() = appContext.reactContext ?: throw Exceptions.AppContextLost() private fun getMD5HashOfFilePath(uri: URI): String { val md = MessageDigest.getInstance("MD5") return md.digest(uri.toString().toByteArray()).joinToString("") { "%02x".format(it) } } private suspend fun downloadAsset(appContext: AppContext, uri: URI, localUrl: File): Uri { if (localUrl.parentFile?.exists() != true) { localUrl.mkdirs() } if (!appContext.filePermission.getPathPermissions(context, requireNotNull(localUrl.parent)) .contains(FilePermissionService.Permission.WRITE) ) { throw UnableToDownloadAssetException(uri.toString()) } return appContext .backgroundCoroutineScope .async { try { val bytesCopied = uri .toInputStream() .use { input -> input.copyTo(localUrl) } if (bytesCopied == 0L) { Log.w("ExpoAsset", "Asset downloaded to $localUrl is empty. It might be conflicting with another asset, or corrupted.") } Uri.fromFile(localUrl) } catch (e: Exception) { throw UnableToDownloadAssetException(uri.toString(), e) } }.await() } private suspend fun URI.toInputStream(): InputStream { val uriString = this.toString() if (!uriString.contains(":")) { return openAssetResourceStream(context, uriString) } if (uriString.startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE)) { return openAndroidResStream(context, uriString) } return openRemoteStream(uriString) ?: throw FailedToDownloadAssetException(uriString) } private fun InputStream.copyTo(file: File): Long { return file.outputStream().use { output -> this.copyTo(output) } } override fun definition() = ModuleDefinition { Name("ExpoAsset") AsyncFunction("downloadAsync") Coroutine { uri: URI, md5Hash: String?, type: String -> if (uri.scheme == "file" && !uri.toString().startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE)) { return@Coroutine uri } val cacheFileId = md5Hash ?: getMD5HashOfFilePath(uri) val cacheDirectory = appContext.cacheDirectory val localUrl = File("$cacheDirectory/ExponentAsset-$cacheFileId.$type") if (!localUrl.exists()) { return@Coroutine downloadAsset(appContext, uri, localUrl) } if (md5Hash == null || md5Hash == getMD5HashOfFileContent(localUrl)) { return@Coroutine Uri.fromFile(localUrl) } return@Coroutine downloadAsset(appContext, uri, localUrl) } } }