chore: add mobile app

This commit is contained in:
2026-02-07 22:33:02 +01:00
parent db34640acc
commit e89384ad4e
29327 changed files with 3886944 additions and 12 deletions
+22
View File
@@ -0,0 +1,22 @@
plugins {
id 'com.android.library'
id 'expo-module-gradle-plugin'
}
group = 'expo.modules.asset'
version = '12.0.12'
android {
namespace "expo.modules.asset"
defaultConfig {
versionCode 1
versionName "12.0.12"
}
}
dependencies {
testImplementation 'androidx.test:core:1.6.1'
testImplementation 'io.mockk:mockk:1.13.11'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.14.1'
}
@@ -0,0 +1,2 @@
<manifest>
</manifest>
@@ -0,0 +1,103 @@
package expo.modules.asset
import android.content.Context
import android.net.Uri
import android.util.Log
import expo.modules.interfaces.filesystem.Permission
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 kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.net.URI
import java.security.DigestInputStream
import java.security.MessageDigest
internal class UnableToDownloadAssetException(url: String) :
CodedException("Unable 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 fun getMD5HashOfFileContent(file: File): String? {
return try {
FileInputStream(file).use { inputStream ->
DigestInputStream(
inputStream,
MessageDigest.getInstance("MD5")
).use { digestInputStream ->
digestInputStream.messageDigest.digest().joinToString(separator = "") { "%02x".format(it) }
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
private suspend fun downloadAsset(appContext: AppContext, uri: URI, localUrl: File): Uri {
if (localUrl.parentFile?.exists() != true) {
localUrl.mkdirs()
}
if (appContext.filePermission?.getPathPermissions(appContext.reactContext, localUrl.parent)?.contains(Permission.WRITE) != true) {
throw UnableToDownloadAssetException(uri.toString())
}
return withContext(appContext.backgroundCoroutineScope.coroutineContext) {
try {
val inputStream = when {
uri.toString().contains(":").not() -> openAssetResourceStream(context, uri.toString())
uri.toString().startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE) -> openAndroidResStream(context, uri.toString())
else -> uri.toURL().openStream()
}
inputStream.use { input ->
localUrl.outputStream().use { output ->
val bytesCopied = input.copyTo(output)
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())
}
}
}
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)
}
}
}
@@ -0,0 +1,61 @@
package expo.modules.asset
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.Resources
import androidx.core.net.toUri
import expo.modules.core.errors.InvalidArgumentException
import java.io.InputStream
internal const val ANDROID_EMBEDDED_URL_BASE_RESOURCE = "file:///android_res/"
/**
* Opens an Android resource as stream.
*/
internal fun openAssetResourceStream(context: Context, assetName: String): InputStream {
val resId = findResourceId(context, assetName) ?: throw Resources.NotFoundException(assetName)
return context.resources.openRawResource(resId)
}
/**
* Opens an Android resource as stream for `file:///android_res/` format
*/
internal fun openAndroidResStream(context: Context, resourceFilePath: String): InputStream {
val resId = findResourceIdForAndroidResPath(context, resourceFilePath)
?: throw Resources.NotFoundException(resourceFilePath)
return context.resources.openRawResource(resId)
}
@SuppressLint("DiscouragedApi")
private fun findResourceId(context: Context, assetName: String): Int? {
val resources = context.resources
val packageName = context.packageName
// react-native core and expo-assets plugin will put resource in `res/raw` or `res/drawable`
return resources.getIdentifier(assetName, "raw", packageName).takeIf { it != 0 }
?: resources.getIdentifier(assetName, "drawable", packageName).takeIf { it != 0 }
}
@SuppressLint("DiscouragedApi")
private fun findResourceIdForAndroidResPath(context: Context, resourceFilePath: String): Int? {
if (!resourceFilePath.startsWith(ANDROID_EMBEDDED_URL_BASE_RESOURCE)) {
throw InvalidArgumentException("Invalid resource file path: $resourceFilePath")
}
val uri = resourceFilePath.toUri()
val pathSegments = uri.pathSegments
if (pathSegments.size < 3) {
throw InvalidArgumentException("Invalid resource file path: $resourceFilePath")
}
// Strip any qualifiers after a dash, for example "drawable-xhdpi" becomes "drawable"
val resourceDirectory = pathSegments[1].substringBefore('-')
// Strip file extension for resource name
val resourceFilename = pathSegments[2]
val resourceName = resourceFilename.substringBeforeLast('.', resourceFilename)
return context.resources.getIdentifier(
resourceName,
resourceDirectory,
context.packageName
).takeIf { it != 0 }
}