Skip to content

Commit 15a867e

Browse files
fix: wrong handle of assets files in release apk (#41)
## Description The file loaded from React-Native assets is served in a different way in debug and release mode, for now in release mode it was blocking applications from working ### Type of change - [x] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [x] Documentation update (improves or adds clarity to existing documentation) ### Tested on - [x] iOS - [x] Android ### Testing instructions <!-- Provide step-by-step instructions on how to test your changes. Include setup details if necessary. --> ### Screenshots <!-- Add screenshots here, if applicable --> ### Related issues <!-- Link related issues here using #issue-number --> ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes <!-- Include any additional information, assumptions, or context that reviewers might need to understand this PR. -->
1 parent 504ac56 commit 15a867e

File tree

4 files changed

+140
-68
lines changed

4 files changed

+140
-68
lines changed

android/src/main/java/com/swmansion/rnexecutorch/ETModule.kt

+3-2
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ class ETModule(reactContext: ReactApplicationContext) : NativeETModuleSpec(react
2222
}
2323

2424
private fun downloadModel(
25-
url: URL, resourceType: ResourceType, callback: (path: String?, error: Exception?) -> Unit
25+
url: String, resourceType: ResourceType, callback: (path: String?, error: Exception?) -> Unit
2626
) {
2727
Fetcher.downloadResource(reactApplicationContext,
2828
client,
2929
url,
3030
resourceType,
31+
false,
3132
{ path, error -> callback(path, error) },
3233
object : ProgressResponseBody.ProgressListener {
3334
override fun onProgress(bytesRead: Long, contentLength: Long, done: Boolean) {
@@ -38,7 +39,7 @@ class ETModule(reactContext: ReactApplicationContext) : NativeETModuleSpec(react
3839
override fun loadModule(modelPath: String, promise: Promise) {
3940
try {
4041
downloadModel(
41-
URL(modelPath), ResourceType.MODEL
42+
modelPath, ResourceType.MODEL
4243
) { path, error ->
4344
if (error != null) {
4445
promise.reject(error.message!!, "-1")

android/src/main/java/com/swmansion/rnexecutorch/RnExecutorchModule.kt

+6-11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
package com.swmansion.rnexecutorch
22

3-
import android.os.Build
43
import android.util.Log
5-
import androidx.annotation.RequiresApi
64
import com.facebook.react.bridge.Promise
75
import com.facebook.react.bridge.ReactApplicationContext
86
import com.swmansion.rnexecutorch.utils.Fetcher
@@ -47,12 +45,13 @@ class RnExecutorchModule(reactContext: ReactApplicationContext) :
4745
}
4846

4947
private fun downloadResource(
50-
url: URL,
48+
url: String,
5149
resourceType: ResourceType,
52-
callback: (path: String?, error: Exception?) -> Unit
50+
isLargeFile: Boolean = false,
51+
callback: (path: String?, error: Exception?) -> Unit,
5352
) {
5453
Fetcher.downloadResource(
55-
reactApplicationContext, client, url, resourceType,
54+
reactApplicationContext, client, url, resourceType, isLargeFile,
5655
{ path, error -> callback(path, error) },
5756
object : ProgressResponseBody.ProgressListener {
5857
override fun onProgress(bytesRead: Long, contentLength: Long, done: Boolean) {
@@ -71,7 +70,6 @@ class RnExecutorchModule(reactContext: ReactApplicationContext) :
7170
promise.resolve("Model loaded successfully")
7271
}
7372

74-
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
7573
override fun loadLLM(
7674
modelSource: String,
7775
tokenizerSource: String,
@@ -85,14 +83,12 @@ class RnExecutorchModule(reactContext: ReactApplicationContext) :
8583
}
8684

8785
try {
88-
val modelURL = URL(modelSource)
89-
val tokenizerURL = URL(tokenizerSource)
9086
this.conversationManager = ConversationManager(contextWindowLength.toInt(), systemPrompt)
9187

9288
isFetching = true
9389

9490
downloadResource(
95-
tokenizerURL,
91+
tokenizerSource,
9692
ResourceType.TOKENIZER
9793
) tokenizerDownload@{ tokenizerPath, error ->
9894
if (error != null) {
@@ -101,7 +97,7 @@ class RnExecutorchModule(reactContext: ReactApplicationContext) :
10197
return@tokenizerDownload
10298
}
10399

104-
downloadResource(modelURL, ResourceType.MODEL) modelDownload@{ modelPath, modelError ->
100+
downloadResource(modelSource, ResourceType.MODEL, isLargeFile = true) modelDownload@{ modelPath, modelError ->
105101
if (modelError != null) {
106102
promise.reject(
107103
"Download Error",
@@ -120,7 +116,6 @@ class RnExecutorchModule(reactContext: ReactApplicationContext) :
120116
}
121117
}
122118

123-
@RequiresApi(Build.VERSION_CODES.N)
124119
override fun runInference(
125120
input: String,
126121
promise: Promise

android/src/main/java/com/swmansion/rnexecutorch/utils/Fetcher.kt

+127-55
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ class Fetcher {
3131
return file
3232
}
3333

34-
private fun hasValidExtension(fileName: String, resourceType: ResourceType): Boolean {
34+
private fun getValidExtension(resourceType: ResourceType): String {
3535
return when (resourceType) {
3636
ResourceType.TOKENIZER -> {
37-
fileName.endsWith(".bin")
37+
"bin"
3838
}
3939

4040
ResourceType.MODEL -> {
41-
fileName.endsWith(".pte")
41+
"pte"
4242
}
4343
}
4444
}
@@ -47,17 +47,9 @@ class Fetcher {
4747
if (url.path == "/assets/") {
4848
val pathSegments = url.toString().split('/')
4949
return pathSegments[pathSegments.size - 1].split("?")[0]
50-
} else if (url.protocol == "file") {
51-
val localPath = url.toString().split("://")[1]
52-
val file = File(localPath)
53-
if (file.exists()) {
54-
return localPath
55-
}
56-
57-
throw Exception("file_not_found")
58-
} else {
59-
return url.path.substringAfterLast('/')
6050
}
51+
52+
return url.path.substringAfterLast('/')
6153
}
6254

6355
private fun fetchModel(
@@ -132,48 +124,74 @@ class Fetcher {
132124
return response
133125
}
134126

135-
fun downloadResource(
127+
private fun getIdOfResource(
136128
context: Context,
137-
client: OkHttpClient,
129+
resourceName: String,
130+
defType: String = "raw"
131+
): Int {
132+
return context.resources.getIdentifier(resourceName, defType, context.packageName)
133+
}
134+
135+
private fun getResourceFromAssets(
136+
context: Context,
137+
url: String,
138+
resourceType: ResourceType,
139+
onComplete: (String?, Exception?) -> Unit
140+
) {
141+
if (!url.contains("://")) {
142+
//The provided file is from react-native assets folder in release mode
143+
val resId = getIdOfResource(context, url)
144+
val resName = context.resources.getResourceEntryName(resId)
145+
val fileExtension = getValidExtension(resourceType)
146+
context.resources.openRawResource(resId).use { inputStream ->
147+
val file = File(
148+
context.filesDir,
149+
"$resName.$fileExtension"
150+
)
151+
file.outputStream().use { outputStream ->
152+
inputStream.copyTo(outputStream)
153+
}
154+
onComplete(file.absolutePath, null)
155+
return
156+
}
157+
}
158+
}
159+
160+
private fun getLocalFile(
138161
url: URL,
139162
resourceType: ResourceType,
140-
onComplete: (String?, Exception?) -> Unit,
141-
listener: ProgressResponseBody.ProgressListener? = null,
163+
onComplete: (String?, Exception?) -> Unit
142164
) {
143-
/*
144-
Fetching model and tokenizer file
145-
1. Extract file name from provided URL
146-
2. If file name contains / it means that the file is local and we should return the path
147-
3. Check if the file has a valid extension
148-
a. For tokenizer, the extension should be .bin
149-
b. For model, the extension should be .pte
150-
4. Check if models directory exists, if not create it
151-
5. Check if the file already exists in the models directory, if yes return the path
152-
6. If the file does not exist, and is a tokenizer, fetch the file
153-
7. If the file is a model, fetch the file with ProgressResponseBody
154-
*/
155-
val fileName: String
165+
// The provided file is a local file, get rid of the file:// prefix and return path
166+
if (url.protocol == "file") {
167+
val localPath = url.path
168+
if (getValidExtension(resourceType) != localPath.takeLast(3)) {
169+
throw Exception("invalid_extension")
170+
}
156171

157-
try {
158-
fileName = extractFileName(url)
159-
} catch (e: Exception) {
160-
onComplete(null, e)
161-
return
162-
}
172+
val file = File(localPath)
173+
if (file.exists()) {
174+
onComplete(localPath, null)
175+
return
176+
}
163177

164-
if (fileName.contains("/")) {
165-
onComplete(fileName, null)
166-
return
178+
throw Exception("file_not_found")
167179
}
180+
}
168181

169-
if (!hasValidExtension(fileName, resourceType)) {
170-
onComplete(null, Exception("invalid_resource_extension"))
171-
return
172-
}
182+
private fun getRemoteFile(
183+
context: Context,
184+
client: OkHttpClient,
185+
url: URL,
186+
resourceType: ResourceType,
187+
isLargeFile: Boolean,
188+
onComplete: (String?, Exception?) -> Unit,
189+
listener: ProgressResponseBody.ProgressListener?
190+
) {
191+
val fileName = extractFileName(url)
173192

174-
val tempFile = File(context.filesDir, fileName)
175-
if (tempFile.exists()) {
176-
tempFile.delete()
193+
if (getValidExtension(resourceType) != fileName.takeLast(3)) {
194+
throw Exception("invalid_extension")
177195
}
178196

179197
val modelsDirectory = File(context.filesDir, "models").apply {
@@ -188,29 +206,83 @@ class Fetcher {
188206
return
189207
}
190208

191-
if (resourceType == ResourceType.TOKENIZER) {
209+
// If the url is a Software Mansion HuggingFace repo, we want to send a HEAD
210+
// request to the config.json file, this increments HF download counter
211+
// https://huggingface.co/docs/hub/models-download-stats
212+
if (isUrlPointingToHfRepo(url)) {
213+
val configUrl = resolveConfigUrlFromModelUrl(url)
214+
sendRequestToUrl(configUrl, "HEAD", null, client)
215+
}
216+
217+
if (!isLargeFile) {
192218
val request = Request.Builder().url(url).build()
193219
val response = client.newCall(request).execute()
194220

195221
if (!response.isSuccessful) {
196-
onComplete(null, Exception("download_error"))
197-
return
222+
throw Exception("download_error")
198223
}
199224

200225
validFile = saveResponseToFile(response, modelsDirectory, fileName)
201226
onComplete(validFile.absolutePath, null)
202227
return
203228
}
204229

205-
// If the url is a Software Mansion HuggingFace repo, we want to send a HEAD
206-
// request to the config.json file, this increments HF download counter
207-
// https://huggingface.co/docs/hub/models-download-stats
208-
if (isUrlPointingToHfRepo(url)) {
209-
val configUrl = resolveConfigUrlFromModelUrl(url)
210-
sendRequestToUrl(configUrl, "HEAD", null, client)
230+
val tempFile = File(context.filesDir, fileName)
231+
if (tempFile.exists()) {
232+
tempFile.delete()
211233
}
212234

213235
fetchModel(tempFile, validFile, client, url, onComplete, listener)
214236
}
237+
238+
fun downloadResource(
239+
context: Context,
240+
client: OkHttpClient,
241+
url: String,
242+
resourceType: ResourceType,
243+
isLargeFile: Boolean,
244+
onComplete: (String?, Exception?) -> Unit,
245+
listener: ProgressResponseBody.ProgressListener? = null,
246+
) {
247+
/*
248+
Fetching model and tokenizer file
249+
1. Check if the provided file is a bundled local file
250+
a. Check if it exists
251+
b. Check if it has valid extension
252+
c. Copy the file and return the path
253+
2. Check if the provided file is a path to a local file
254+
a. Check if it exists
255+
b. Check if it has valid extension
256+
c. Return the path
257+
3. The provided file is a remote file
258+
a. Check if it has valid extension
259+
b. Check if it's a large file
260+
i. Create temporary file to store it at download time
261+
ii. Move it to the models directory and return the path
262+
c. If it's not a large file download it and return the path
263+
*/
264+
265+
try {
266+
getResourceFromAssets(context, url, resourceType, onComplete)
267+
268+
val resUrl = URL(url)
269+
/*
270+
The provided file is either a remote file or a local file
271+
- local file: file:///path/to/file
272+
- remote file: https://path/to/file || http://10.0.2.2:8080/path/to/file
273+
*/
274+
getLocalFile(resUrl, resourceType, onComplete)
275+
276+
/*
277+
The provided file is a remote file, if it's a large file
278+
create temporary file to store it at download time and later
279+
move it to the models directory
280+
*/
281+
getRemoteFile(context, client, resUrl, resourceType, isLargeFile, onComplete, listener)
282+
} catch (e: Exception) {
283+
onComplete(null, e)
284+
return
285+
}
286+
}
215287
}
216288
}

docs/docs/fundamentals/getting-started.mdx

+4
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ This allows us to use binaries, such as exported models or tokenizers for LLMs.
4949
When using Expo, please note that you need to use a custom development build of your app, not the standard Expo Go app. This is because we rely on native modules, which Expo Go doesn’t support.
5050
:::
5151

52+
:::info[Info]
53+
Because we are using ExecuTorch under the hood, you won't be able to build ios app for release with simulator selected as the target device. Make sure to test release builds on real devices.
54+
:::
55+
5256
Running the app with the library:
5357
```bash
5458
yarn run expo:<ios | android> -d

0 commit comments

Comments
 (0)