Skip to content

Commit 3fbbec4

Browse files
feat: add android fs native modules for substitution
1 parent 0dad24b commit 3fbbec4

File tree

13 files changed

+1565
-158
lines changed

13 files changed

+1565
-158
lines changed

apps/fs-experiment/App.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import SandboxReactNativeView from '@callstack/react-native-sandbox'
22
import React, {useState} from 'react'
33
import {
4+
Platform,
45
SafeAreaView,
56
ScrollView,
67
StatusBar,
@@ -13,12 +14,15 @@ import {
1314

1415
import FileOpsUI from './FileOpsUI'
1516

16-
const ALL_TURBO_MODULES = ['RNFSManager', 'FileAccess', 'PlatformLocalStorage']
17+
const ASYNC_STORAGE_MODULE =
18+
Platform.OS === 'ios' ? 'PlatformLocalStorage' : 'RNCAsyncStorage'
19+
20+
const ALL_TURBO_MODULES = ['RNFSManager', 'FileAccess', ASYNC_STORAGE_MODULE]
1721

1822
const SANDBOXED_SUBSTITUTIONS: Record<string, string> = {
1923
RNFSManager: 'SandboxedRNFSManager',
2024
FileAccess: 'SandboxedFileAccess',
21-
PlatformLocalStorage: 'SandboxedAsyncStorage',
25+
[ASYNC_STORAGE_MODULE]: 'SandboxedAsyncStorage',
2226
}
2327

2428
function App(): React.JSX.Element {

apps/fs-experiment/FileOpsUI.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@ import {
1212
View,
1313
} from 'react-native'
1414
import {Dirs, FileSystem} from 'react-native-file-access'
15-
import RNFS from 'react-native-fs'
15+
16+
// react-native-fs doesn't support TurboModules. Its top-level code accesses
17+
// NativeModules.RNFSManager constants synchronously, throwing if null.
18+
// Wrap require() so the app still works when RNFS isn't available.
19+
let RNFS: any
20+
try {
21+
RNFS = require('react-native-fs').default ?? require('react-native-fs')
22+
} catch {
23+
RNFS = {DocumentDirectoryPath: Dirs.DocumentDir}
24+
}
1625

1726
const MODULES = [
1827
{key: 'rnfs', label: 'RNFS'},

apps/fs-experiment/android/app/src/main/java/com/multinstance/fsexperiment/MainApplication.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
1111
import com.facebook.react.defaults.DefaultReactNativeHost
1212
import com.facebook.react.soloader.OpenSourceMergedSoMapping
1313
import com.facebook.soloader.SoLoader
14+
import io.callstack.rnsandbox.SandboxReactNativeDelegate
1415

1516
class MainApplication : Application(), ReactApplication {
1617

@@ -37,8 +38,9 @@ class MainApplication : Application(), ReactApplication {
3738
super.onCreate()
3839
SoLoader.init(this, OpenSourceMergedSoMapping)
3940
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
40-
// If you opted-in for the New Architecture, we load the native entry point for this app.
4141
load()
4242
}
43+
SandboxReactNativeDelegate.registerHostPackages(PackageList(this).packages)
44+
SandboxReactNativeDelegate.registerSubstitutionPackages(SandboxedModulesPackage())
4345
}
4446
}
Lines changed: 332 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,332 @@
1+
package com.multinstance.fsexperiment
2+
3+
import android.content.ContentValues
4+
import android.content.Context
5+
import android.database.sqlite.SQLiteDatabase
6+
import android.database.sqlite.SQLiteOpenHelper
7+
import android.util.Log
8+
import com.facebook.react.bridge.Arguments
9+
import com.facebook.react.bridge.Callback
10+
import com.facebook.react.bridge.ReactApplicationContext
11+
import com.facebook.react.bridge.ReactContextBaseJavaModule
12+
import com.facebook.react.bridge.ReactMethod
13+
import com.facebook.react.bridge.ReadableArray
14+
import com.facebook.react.bridge.WritableMap
15+
import com.facebook.react.module.annotations.ReactModule
16+
import io.callstack.rnsandbox.SandboxAwareModule
17+
import org.json.JSONObject
18+
import java.util.concurrent.Executors
19+
import java.util.concurrent.TimeUnit
20+
21+
/**
22+
* Sandboxed AsyncStorage — per-origin SQLite storage that mirrors the original
23+
* RNCAsyncStorage API but scopes data to the sandbox origin.
24+
*
25+
* Uses callbacks (not promises) to match the original AsyncStorageModule interface.
26+
*/
27+
@ReactModule(name = SandboxedAsyncStorage.MODULE_NAME)
28+
class SandboxedAsyncStorage(
29+
private val reactContext: ReactApplicationContext,
30+
) : ReactContextBaseJavaModule(reactContext), SandboxAwareModule {
31+
32+
companion object {
33+
const val MODULE_NAME = "SandboxedAsyncStorage"
34+
private const val TAG = "SandboxedAsyncStorage"
35+
private const val TABLE = "kv"
36+
private const val COL_KEY = "k"
37+
private const val COL_VALUE = "v"
38+
private const val DB_VERSION = 1
39+
private const val MAX_SQL_KEYS = 999
40+
}
41+
42+
private val executor = Executors.newSingleThreadExecutor()
43+
private var dbHelper: SandboxDBHelper? = null
44+
@Volatile private var configured = false
45+
46+
override fun getName(): String = MODULE_NAME
47+
48+
override fun configureSandbox(origin: String, requestedName: String, resolvedName: String) {
49+
Log.d(TAG, "Configuring for origin '$origin'")
50+
val dbDir = java.io.File(reactContext.filesDir, "Sandboxes/$origin/AsyncStorage")
51+
dbDir.mkdirs()
52+
val dbName = "sandboxed_async_storage.db"
53+
dbHelper = SandboxDBHelper(reactContext, java.io.File(dbDir, dbName).absolutePath)
54+
configured = true
55+
}
56+
57+
override fun invalidate() {
58+
executor.shutdown()
59+
try {
60+
executor.awaitTermination(2, TimeUnit.SECONDS)
61+
} catch (_: InterruptedException) {
62+
executor.shutdownNow()
63+
}
64+
dbHelper?.close()
65+
dbHelper = null
66+
configured = false
67+
super.invalidate()
68+
}
69+
70+
private fun errorMap(message: String): WritableMap {
71+
val map = Arguments.createMap()
72+
map.putString("message", message)
73+
return map
74+
}
75+
76+
private fun db(): SQLiteDatabase? = dbHelper?.writableDatabase
77+
78+
private fun readDb(): SQLiteDatabase? = dbHelper?.readableDatabase
79+
80+
@ReactMethod
81+
fun multiGet(keys: ReadableArray, callback: Callback) {
82+
if (!configured) {
83+
callback.invoke(errorMap("Sandbox not configured"), null)
84+
return
85+
}
86+
executor.execute {
87+
try {
88+
val db = readDb() ?: run {
89+
callback.invoke(errorMap("Database not available"), null)
90+
return@execute
91+
}
92+
val data = Arguments.createArray()
93+
val keysRemaining = mutableSetOf<String>()
94+
95+
for (start in 0 until keys.size() step MAX_SQL_KEYS) {
96+
val count = minOf(keys.size() - start, MAX_SQL_KEYS)
97+
keysRemaining.clear()
98+
val placeholders = (0 until count).joinToString(",") { "?" }
99+
val args = Array(count) { keys.getString(start + it) ?: "" }
100+
for (arg in args) keysRemaining.add(arg)
101+
102+
db.rawQuery("SELECT $COL_KEY, $COL_VALUE FROM $TABLE WHERE $COL_KEY IN ($placeholders)", args).use { cursor ->
103+
while (cursor.moveToNext()) {
104+
val row = Arguments.createArray()
105+
row.pushString(cursor.getString(0))
106+
row.pushString(cursor.getString(1))
107+
data.pushArray(row)
108+
keysRemaining.remove(cursor.getString(0))
109+
}
110+
}
111+
112+
for (key in keysRemaining) {
113+
val row = Arguments.createArray()
114+
row.pushString(key)
115+
row.pushNull()
116+
data.pushArray(row)
117+
}
118+
}
119+
callback.invoke(null, data)
120+
} catch (e: Exception) {
121+
Log.e(TAG, "multiGet failed", e)
122+
callback.invoke(errorMap(e.message ?: "Unknown error"), null)
123+
}
124+
}
125+
}
126+
127+
@ReactMethod
128+
fun multiSet(keyValueArray: ReadableArray, callback: Callback) {
129+
if (!configured) {
130+
callback.invoke(errorMap("Sandbox not configured"))
131+
return
132+
}
133+
executor.execute {
134+
try {
135+
val db = db() ?: run {
136+
callback.invoke(errorMap("Database not available"))
137+
return@execute
138+
}
139+
db.beginTransaction()
140+
try {
141+
for (i in 0 until keyValueArray.size()) {
142+
val pair = keyValueArray.getArray(i) ?: continue
143+
if (pair.size() != 2) continue
144+
val key = pair.getString(0) ?: continue
145+
val value = pair.getString(1) ?: continue
146+
147+
val cv = ContentValues()
148+
cv.put(COL_KEY, key)
149+
cv.put(COL_VALUE, value)
150+
db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
151+
}
152+
db.setTransactionSuccessful()
153+
} finally {
154+
db.endTransaction()
155+
}
156+
callback.invoke()
157+
} catch (e: Exception) {
158+
Log.e(TAG, "multiSet failed", e)
159+
callback.invoke(errorMap(e.message ?: "Unknown error"))
160+
}
161+
}
162+
}
163+
164+
@ReactMethod
165+
fun multiRemove(keys: ReadableArray, callback: Callback) {
166+
if (!configured) {
167+
callback.invoke(errorMap("Sandbox not configured"))
168+
return
169+
}
170+
executor.execute {
171+
try {
172+
val db = db() ?: run {
173+
callback.invoke(errorMap("Database not available"))
174+
return@execute
175+
}
176+
db.beginTransaction()
177+
try {
178+
for (start in 0 until keys.size() step MAX_SQL_KEYS) {
179+
val count = minOf(keys.size() - start, MAX_SQL_KEYS)
180+
val placeholders = (0 until count).joinToString(",") { "?" }
181+
val args = Array(count) { keys.getString(start + it) ?: "" }
182+
db.delete(TABLE, "$COL_KEY IN ($placeholders)", args)
183+
}
184+
db.setTransactionSuccessful()
185+
} finally {
186+
db.endTransaction()
187+
}
188+
callback.invoke()
189+
} catch (e: Exception) {
190+
Log.e(TAG, "multiRemove failed", e)
191+
callback.invoke(errorMap(e.message ?: "Unknown error"))
192+
}
193+
}
194+
}
195+
196+
@ReactMethod
197+
fun multiMerge(keyValueArray: ReadableArray, callback: Callback) {
198+
if (!configured) {
199+
callback.invoke(errorMap("Sandbox not configured"))
200+
return
201+
}
202+
executor.execute {
203+
try {
204+
val db = db() ?: run {
205+
callback.invoke(errorMap("Database not available"))
206+
return@execute
207+
}
208+
db.beginTransaction()
209+
try {
210+
for (i in 0 until keyValueArray.size()) {
211+
val pair = keyValueArray.getArray(i) ?: continue
212+
if (pair.size() != 2) continue
213+
val key = pair.getString(0) ?: continue
214+
val newValue = pair.getString(1) ?: continue
215+
216+
val existing = getValueForKey(db, key)
217+
val merged = if (existing != null) {
218+
mergeJsonStrings(existing, newValue) ?: newValue
219+
} else {
220+
newValue
221+
}
222+
val cv = ContentValues()
223+
cv.put(COL_KEY, key)
224+
cv.put(COL_VALUE, merged)
225+
db.insertWithOnConflict(TABLE, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
226+
}
227+
db.setTransactionSuccessful()
228+
} finally {
229+
db.endTransaction()
230+
}
231+
callback.invoke()
232+
} catch (e: Exception) {
233+
Log.e(TAG, "multiMerge failed", e)
234+
callback.invoke(errorMap(e.message ?: "Unknown error"))
235+
}
236+
}
237+
}
238+
239+
@ReactMethod
240+
fun getAllKeys(callback: Callback) {
241+
if (!configured) {
242+
callback.invoke(errorMap("Sandbox not configured"), null)
243+
return
244+
}
245+
executor.execute {
246+
try {
247+
val db = readDb() ?: run {
248+
callback.invoke(errorMap("Database not available"), null)
249+
return@execute
250+
}
251+
val keys = Arguments.createArray()
252+
db.rawQuery("SELECT $COL_KEY FROM $TABLE", null).use { cursor ->
253+
while (cursor.moveToNext()) {
254+
keys.pushString(cursor.getString(0))
255+
}
256+
}
257+
callback.invoke(null, keys)
258+
} catch (e: Exception) {
259+
Log.e(TAG, "getAllKeys failed", e)
260+
callback.invoke(errorMap(e.message ?: "Unknown error"), null)
261+
}
262+
}
263+
}
264+
265+
@ReactMethod
266+
fun clear(callback: Callback) {
267+
if (!configured) {
268+
callback.invoke(errorMap("Sandbox not configured"))
269+
return
270+
}
271+
executor.execute {
272+
try {
273+
val db = db() ?: run {
274+
callback.invoke(errorMap("Database not available"))
275+
return@execute
276+
}
277+
db.delete(TABLE, null, null)
278+
callback.invoke()
279+
} catch (e: Exception) {
280+
Log.e(TAG, "clear failed", e)
281+
callback.invoke(errorMap(e.message ?: "Unknown error"))
282+
}
283+
}
284+
}
285+
286+
private fun getValueForKey(db: SQLiteDatabase, key: String): String? {
287+
db.rawQuery("SELECT $COL_VALUE FROM $TABLE WHERE $COL_KEY = ?", arrayOf(key)).use { cursor ->
288+
return if (cursor.moveToFirst()) cursor.getString(0) else null
289+
}
290+
}
291+
292+
/**
293+
* Deep recursive merge matching the original RNCAsyncStorage behavior:
294+
* when both sides have a JSONObject at a given key, merge recursively;
295+
* otherwise the new value overwrites the old one.
296+
*/
297+
private fun mergeJsonStrings(existing: String, incoming: String): String? {
298+
return try {
299+
val base = JSONObject(existing)
300+
val overlay = JSONObject(incoming)
301+
deepMerge(base, overlay)
302+
base.toString()
303+
} catch (e: Exception) {
304+
null
305+
}
306+
}
307+
308+
private fun deepMerge(base: JSONObject, overlay: JSONObject) {
309+
for (key in overlay.keys()) {
310+
val newValue = overlay.get(key)
311+
val oldValue = base.opt(key)
312+
if (oldValue is JSONObject && newValue is JSONObject) {
313+
deepMerge(oldValue, newValue)
314+
} else {
315+
base.put(key, newValue)
316+
}
317+
}
318+
}
319+
320+
private class SandboxDBHelper(context: Context, dbPath: String) :
321+
SQLiteOpenHelper(context, dbPath, null, DB_VERSION) {
322+
323+
override fun onCreate(db: SQLiteDatabase) {
324+
db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE ($COL_KEY TEXT PRIMARY KEY, $COL_VALUE TEXT NOT NULL)")
325+
}
326+
327+
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
328+
db.execSQL("DROP TABLE IF EXISTS $TABLE")
329+
onCreate(db)
330+
}
331+
}
332+
}

0 commit comments

Comments
 (0)