@sigx/lynx-dev-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/LICENSE +21 -0
  2. package/android/src/main/kotlin/com/sigx/devclient/DevGenericResourceFetcher.kt +73 -0
  3. package/android/src/main/kotlin/com/sigx/devclient/DevHomeScreen.kt +163 -0
  4. package/android/src/main/kotlin/com/sigx/devclient/DevLynxScreen.kt +208 -0
  5. package/android/src/main/kotlin/com/sigx/devclient/DevMenu.kt +278 -0
  6. package/android/src/main/kotlin/com/sigx/devclient/DevQRScanner.kt +174 -0
  7. package/android/src/main/kotlin/com/sigx/devclient/DevSettings.kt +61 -0
  8. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateProvider.kt +76 -0
  9. package/android/src/main/kotlin/com/sigx/devclient/DevTemplateResourceFetcher.kt +113 -0
  10. package/android/src/main/kotlin/com/sigx/devclient/ErrorOverlay.kt +86 -0
  11. package/android/src/main/kotlin/com/sigx/devclient/PerfHud.kt +100 -0
  12. package/android/src/main/kotlin/com/sigx/devclient/ShakeDetector.kt +97 -0
  13. package/android/src/main/kotlin/com/sigx/devclient/SigxDevClient.kt +91 -0
  14. package/dist/index.d.ts +11 -0
  15. package/dist/index.js +11 -0
  16. package/ios/Sources/SigxDevClient/DevGenericResourceFetcher.swift +47 -0
  17. package/ios/Sources/SigxDevClient/DevHomeScreen.swift +128 -0
  18. package/ios/Sources/SigxDevClient/DevMenuView.swift +133 -0
  19. package/ios/Sources/SigxDevClient/DevQRScanner.swift +165 -0
  20. package/ios/Sources/SigxDevClient/DevTemplateProvider.swift +68 -0
  21. package/ios/Sources/SigxDevClient/DevTemplateResourceFetcher.swift +73 -0
  22. package/ios/Sources/SigxDevClient/ShakeDetector.swift +60 -0
  23. package/ios/Sources/SigxDevClient/SigxDevClient.swift +106 -0
  24. package/package.json +30 -0
@@ -0,0 +1,278 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
5
+ import android.content.Context
6
+ import android.widget.Toast
7
+ import androidx.compose.foundation.clickable
8
+ import androidx.compose.foundation.layout.*
9
+ import androidx.compose.foundation.text.KeyboardActions
10
+ import androidx.compose.foundation.text.KeyboardOptions
11
+ import androidx.compose.material.icons.Icons
12
+ import androidx.compose.material.icons.filled.*
13
+ import androidx.compose.material3.*
14
+ import androidx.compose.runtime.*
15
+ import androidx.compose.ui.Alignment
16
+ import androidx.compose.ui.Modifier
17
+ import androidx.compose.ui.graphics.vector.ImageVector
18
+ import androidx.compose.ui.platform.LocalContext
19
+ import androidx.compose.ui.text.input.ImeAction
20
+ import androidx.compose.ui.text.input.KeyboardType
21
+ import androidx.compose.ui.unit.dp
22
+
23
+ data class DevMenuActions(
24
+ val onReload: () -> Unit,
25
+ val onChangeUrl: (String) -> Unit,
26
+ val onGoHome: (() -> Unit)? = null,
27
+ val onTogglePerfHud: () -> Unit,
28
+ val onToggleLogBox: () -> Unit,
29
+ val onToggleInspector: () -> Unit,
30
+ val currentUrl: String,
31
+ val perfHudEnabled: Boolean,
32
+ val logBoxEnabled: Boolean,
33
+ val inspectorEnabled: Boolean,
34
+ /** Optional list of native module names to display. */
35
+ val nativeModules: List<String> = emptyList()
36
+ )
37
+
38
+ @OptIn(ExperimentalMaterial3Api::class)
39
+ @Composable
40
+ fun DevMenu(
41
+ visible: Boolean,
42
+ onDismiss: () -> Unit,
43
+ actions: DevMenuActions
44
+ ) {
45
+ if (!visible) return
46
+
47
+ val context = LocalContext.current
48
+ var showUrlInput by remember { mutableStateOf(false) }
49
+ var newUrl by remember(actions.currentUrl) { mutableStateOf(actions.currentUrl) }
50
+ val sheetState = rememberModalBottomSheetState()
51
+
52
+ ModalBottomSheet(
53
+ onDismissRequest = {
54
+ showUrlInput = false
55
+ onDismiss()
56
+ },
57
+ sheetState = sheetState,
58
+ containerColor = MaterialTheme.colorScheme.surface
59
+ ) {
60
+ Column(
61
+ modifier = Modifier
62
+ .fillMaxWidth()
63
+ .padding(bottom = 32.dp)
64
+ ) {
65
+ Text(
66
+ "sigx Dev Menu",
67
+ style = MaterialTheme.typography.titleMedium,
68
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
69
+ color = MaterialTheme.colorScheme.primary
70
+ )
71
+
72
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
73
+
74
+ // Reload
75
+ DevMenuItem(
76
+ icon = Icons.Default.Refresh,
77
+ label = "Reload",
78
+ subtitle = "Full reload of current bundle"
79
+ ) {
80
+ actions.onReload()
81
+ onDismiss()
82
+ }
83
+
84
+ // Change Server URL
85
+ DevMenuItem(
86
+ icon = Icons.Default.Edit,
87
+ label = "Change Dev Server",
88
+ subtitle = if (showUrlInput) null else actions.currentUrl
89
+ ) {
90
+ showUrlInput = !showUrlInput
91
+ }
92
+
93
+ if (showUrlInput) {
94
+ Row(
95
+ modifier = Modifier
96
+ .fillMaxWidth()
97
+ .padding(horizontal = 24.dp, vertical = 4.dp),
98
+ verticalAlignment = Alignment.CenterVertically
99
+ ) {
100
+ OutlinedTextField(
101
+ value = newUrl,
102
+ onValueChange = { newUrl = it },
103
+ modifier = Modifier.weight(1f),
104
+ singleLine = true,
105
+ label = { Text("Server URL") },
106
+ keyboardOptions = KeyboardOptions(
107
+ keyboardType = KeyboardType.Uri,
108
+ imeAction = ImeAction.Go
109
+ ),
110
+ keyboardActions = KeyboardActions(
111
+ onGo = {
112
+ actions.onChangeUrl(newUrl)
113
+ showUrlInput = false
114
+ onDismiss()
115
+ }
116
+ )
117
+ )
118
+ Spacer(modifier = Modifier.width(8.dp))
119
+ FilledTonalButton(onClick = {
120
+ actions.onChangeUrl(newUrl)
121
+ showUrlInput = false
122
+ onDismiss()
123
+ }) {
124
+ Text("Go")
125
+ }
126
+ }
127
+ }
128
+
129
+ // Copy URL
130
+ DevMenuItem(
131
+ icon = Icons.Default.Share,
132
+ label = "Copy URL",
133
+ subtitle = "Copy current server URL to clipboard"
134
+ ) {
135
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
136
+ clipboard.setPrimaryClip(ClipData.newPlainText("Dev Server URL", actions.currentUrl))
137
+ Toast.makeText(context, "URL copied", Toast.LENGTH_SHORT).show()
138
+ onDismiss()
139
+ }
140
+
141
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
142
+
143
+ // Performance HUD toggle
144
+ DevMenuToggleItem(
145
+ icon = Icons.Default.Info,
146
+ label = "Performance HUD",
147
+ enabled = actions.perfHudEnabled
148
+ ) {
149
+ actions.onTogglePerfHud()
150
+ onDismiss()
151
+ }
152
+
153
+ // LogBox toggle
154
+ DevMenuToggleItem(
155
+ icon = Icons.Default.Warning,
156
+ label = "LogBox",
157
+ enabled = actions.logBoxEnabled
158
+ ) {
159
+ actions.onToggleLogBox()
160
+ onDismiss()
161
+ }
162
+
163
+ // Element Inspector toggle
164
+ DevMenuToggleItem(
165
+ icon = Icons.Default.List,
166
+ label = "Element Inspector",
167
+ enabled = actions.inspectorEnabled
168
+ ) {
169
+ actions.onToggleInspector()
170
+ onDismiss()
171
+ }
172
+
173
+ // Native Modules info (if provided)
174
+ if (actions.nativeModules.isNotEmpty()) {
175
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
176
+
177
+ DevMenuItem(
178
+ icon = Icons.Default.Build,
179
+ label = "Native Modules (${actions.nativeModules.size})",
180
+ subtitle = actions.nativeModules.joinToString(", ")
181
+ ) {
182
+ Toast.makeText(
183
+ context,
184
+ "Available: ${actions.nativeModules.joinToString(", ")}",
185
+ Toast.LENGTH_LONG
186
+ ).show()
187
+ }
188
+ }
189
+
190
+ // Go Home (optional)
191
+ if (actions.onGoHome != null) {
192
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
193
+
194
+ DevMenuItem(
195
+ icon = Icons.Default.Home,
196
+ label = "Go Home",
197
+ subtitle = "Return to URL input screen"
198
+ ) {
199
+ actions.onGoHome!!()
200
+ onDismiss()
201
+ }
202
+ }
203
+ }
204
+ }
205
+ }
206
+
207
+ @Composable
208
+ private fun DevMenuItem(
209
+ icon: ImageVector,
210
+ label: String,
211
+ subtitle: String? = null,
212
+ onClick: () -> Unit
213
+ ) {
214
+ Row(
215
+ modifier = Modifier
216
+ .fillMaxWidth()
217
+ .clickable(onClick = onClick)
218
+ .padding(horizontal = 24.dp, vertical = 12.dp),
219
+ verticalAlignment = Alignment.CenterVertically
220
+ ) {
221
+ Icon(
222
+ imageVector = icon,
223
+ contentDescription = label,
224
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
225
+ modifier = Modifier.size(24.dp)
226
+ )
227
+ Spacer(modifier = Modifier.width(16.dp))
228
+ Column(modifier = Modifier.weight(1f)) {
229
+ Text(
230
+ label,
231
+ style = MaterialTheme.typography.bodyLarge,
232
+ color = MaterialTheme.colorScheme.onSurface
233
+ )
234
+ if (subtitle != null) {
235
+ Text(
236
+ subtitle,
237
+ style = MaterialTheme.typography.bodySmall,
238
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
239
+ maxLines = 1
240
+ )
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ @Composable
247
+ private fun DevMenuToggleItem(
248
+ icon: ImageVector,
249
+ label: String,
250
+ enabled: Boolean,
251
+ onToggle: () -> Unit
252
+ ) {
253
+ Row(
254
+ modifier = Modifier
255
+ .fillMaxWidth()
256
+ .clickable(onClick = onToggle)
257
+ .padding(horizontal = 24.dp, vertical = 12.dp),
258
+ verticalAlignment = Alignment.CenterVertically
259
+ ) {
260
+ Icon(
261
+ imageVector = icon,
262
+ contentDescription = label,
263
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
264
+ modifier = Modifier.size(24.dp)
265
+ )
266
+ Spacer(modifier = Modifier.width(16.dp))
267
+ Text(
268
+ label,
269
+ style = MaterialTheme.typography.bodyLarge,
270
+ color = MaterialTheme.colorScheme.onSurface,
271
+ modifier = Modifier.weight(1f)
272
+ )
273
+ Switch(
274
+ checked = enabled,
275
+ onCheckedChange = { onToggle() }
276
+ )
277
+ }
278
+ }
@@ -0,0 +1,174 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.Manifest
4
+ import android.content.pm.PackageManager
5
+ import android.util.Log
6
+ import androidx.activity.compose.rememberLauncherForActivityResult
7
+ import androidx.activity.result.contract.ActivityResultContracts
8
+ import androidx.camera.core.CameraSelector
9
+ import androidx.camera.core.ImageAnalysis
10
+ import androidx.camera.core.Preview
11
+ import androidx.camera.lifecycle.ProcessCameraProvider
12
+ import androidx.camera.mlkit.vision.MlKitAnalyzer
13
+ import androidx.camera.view.PreviewView
14
+ import androidx.compose.foundation.layout.*
15
+ import androidx.compose.material.icons.Icons
16
+ import androidx.compose.material.icons.automirrored.filled.ArrowBack
17
+ import androidx.compose.material3.*
18
+ import androidx.compose.runtime.*
19
+ import androidx.compose.ui.Alignment
20
+ import androidx.compose.ui.Modifier
21
+ import androidx.compose.ui.platform.LocalContext
22
+ import androidx.compose.ui.unit.dp
23
+ import androidx.compose.ui.viewinterop.AndroidView
24
+ import androidx.core.content.ContextCompat
25
+ import androidx.lifecycle.compose.LocalLifecycleOwner
26
+ import com.google.mlkit.vision.barcode.BarcodeScannerOptions
27
+ import com.google.mlkit.vision.barcode.BarcodeScanning
28
+ import com.google.mlkit.vision.barcode.common.Barcode
29
+
30
+ /**
31
+ * Camera-driven QR scanner for `DevHomeScreen`. Uses CameraX's `MlKitAnalyzer`
32
+ * (the canonical CameraX × ML Kit bridge — no manual `imageProxy.image`
33
+ * wrangling) plus ML Kit barcode-scanning. Reports the first scanned URL/text
34
+ * via `onCodeScanned`; `onBack` is invoked when the user taps the back arrow.
35
+ */
36
+ @OptIn(ExperimentalMaterial3Api::class)
37
+ @Composable
38
+ fun DevQRScanner(
39
+ onCodeScanned: (String) -> Unit,
40
+ onBack: () -> Unit,
41
+ ) {
42
+ val context = LocalContext.current
43
+ val lifecycleOwner = LocalLifecycleOwner.current
44
+ var hasCameraPermission by remember {
45
+ mutableStateOf(
46
+ ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA)
47
+ == PackageManager.PERMISSION_GRANTED,
48
+ )
49
+ }
50
+ val scannedOnce = remember { mutableStateOf(false) }
51
+
52
+ val permissionLauncher = rememberLauncherForActivityResult(
53
+ ActivityResultContracts.RequestPermission(),
54
+ ) { granted ->
55
+ hasCameraPermission = granted
56
+ }
57
+
58
+ LaunchedEffect(Unit) {
59
+ if (!hasCameraPermission) {
60
+ permissionLauncher.launch(Manifest.permission.CAMERA)
61
+ }
62
+ }
63
+
64
+ Scaffold(
65
+ topBar = {
66
+ TopAppBar(
67
+ title = { Text("Scan QR Code") },
68
+ navigationIcon = {
69
+ IconButton(onClick = onBack) {
70
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
71
+ }
72
+ },
73
+ )
74
+ },
75
+ ) { padding ->
76
+ Box(
77
+ modifier = Modifier
78
+ .fillMaxSize()
79
+ .padding(padding),
80
+ ) {
81
+ if (hasCameraPermission) {
82
+ AndroidView(
83
+ modifier = Modifier.fillMaxSize(),
84
+ factory = { ctx ->
85
+ val previewView = PreviewView(ctx)
86
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
87
+
88
+ cameraProviderFuture.addListener({
89
+ val cameraProvider = cameraProviderFuture.get()
90
+
91
+ val preview = Preview.Builder().build().also {
92
+ it.surfaceProvider = previewView.surfaceProvider
93
+ }
94
+
95
+ // Restrict to QR codes for faster processing — the dev
96
+ // server URL is the only thing we expect to scan here.
97
+ val barcodeScanner = BarcodeScanning.getClient(
98
+ BarcodeScannerOptions.Builder()
99
+ .setBarcodeFormats(Barcode.FORMAT_QR_CODE)
100
+ .build(),
101
+ )
102
+
103
+ val imageAnalysis = ImageAnalysis.Builder()
104
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
105
+ .build()
106
+
107
+ val mainExecutor = ContextCompat.getMainExecutor(ctx)
108
+ // COORDINATE_SYSTEM_ORIGINAL is the right choice when
109
+ // wiring ImageAnalysis directly via bindToLifecycle.
110
+ // VIEW_REFERENCED only fires when using
111
+ // LifecycleCameraController + PreviewView.controller,
112
+ // and would silently never invoke the callback here.
113
+ val analyzer = MlKitAnalyzer(
114
+ listOf(barcodeScanner),
115
+ ImageAnalysis.COORDINATE_SYSTEM_ORIGINAL,
116
+ mainExecutor,
117
+ ) { result ->
118
+ if (scannedOnce.value) return@MlKitAnalyzer
119
+ val barcodes = result.getValue(barcodeScanner) ?: return@MlKitAnalyzer
120
+ for (barcode in barcodes) {
121
+ val value = barcode.url?.url ?: barcode.rawValue ?: continue
122
+ if (!scannedOnce.value) {
123
+ scannedOnce.value = true
124
+ onCodeScanned(value)
125
+ return@MlKitAnalyzer
126
+ }
127
+ }
128
+ }
129
+
130
+ imageAnalysis.setAnalyzer(mainExecutor, analyzer)
131
+
132
+ try {
133
+ cameraProvider.unbindAll()
134
+ cameraProvider.bindToLifecycle(
135
+ lifecycleOwner,
136
+ CameraSelector.DEFAULT_BACK_CAMERA,
137
+ preview,
138
+ imageAnalysis,
139
+ )
140
+ } catch (e: Exception) {
141
+ Log.e("DevQRScanner", "Camera bind failed", e)
142
+ }
143
+ }, ContextCompat.getMainExecutor(ctx))
144
+
145
+ previewView
146
+ },
147
+ )
148
+
149
+ Text(
150
+ "Point camera at QR code from dev server",
151
+ modifier = Modifier
152
+ .align(Alignment.BottomCenter)
153
+ .padding(32.dp),
154
+ style = MaterialTheme.typography.bodyLarge,
155
+ color = MaterialTheme.colorScheme.onSurface,
156
+ )
157
+ } else {
158
+ Column(
159
+ modifier = Modifier
160
+ .fillMaxSize()
161
+ .padding(16.dp),
162
+ verticalArrangement = Arrangement.Center,
163
+ horizontalAlignment = Alignment.CenterHorizontally,
164
+ ) {
165
+ Text("Camera permission is required to scan QR codes.")
166
+ Spacer(modifier = Modifier.height(16.dp))
167
+ Button(onClick = { permissionLauncher.launch(Manifest.permission.CAMERA) }) {
168
+ Text("Grant Permission")
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,61 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.content.Context
4
+ import android.content.SharedPreferences
5
+
6
+ /**
7
+ * Persists dev mode preferences across app restarts.
8
+ */
9
+ class DevSettings(context: Context) {
10
+
11
+ private val prefs: SharedPreferences =
12
+ context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
13
+
14
+ var perfHudEnabled: Boolean
15
+ get() = prefs.getBoolean(KEY_PERF_HUD, false)
16
+ set(value) = prefs.edit().putBoolean(KEY_PERF_HUD, value).apply()
17
+
18
+ var logBoxEnabled: Boolean
19
+ get() = prefs.getBoolean(KEY_LOGBOX, true)
20
+ set(value) = prefs.edit().putBoolean(KEY_LOGBOX, value).apply()
21
+
22
+ var lastConnectedUrl: String
23
+ get() = prefs.getString(KEY_LAST_URL, "") ?: ""
24
+ set(value) = prefs.edit().putString(KEY_LAST_URL, value).apply()
25
+
26
+ /** History of dev-server URLs the user has connected to from `DevHomeScreen`, most-recent first. */
27
+ val recentUrls: List<String>
28
+ get() {
29
+ val raw = prefs.getString(KEY_RECENT_URLS, null) ?: return emptyList()
30
+ return raw.split('\n').filter { it.isNotBlank() }
31
+ }
32
+
33
+ /** Insert `url` at the front, dedup, trim to the cap, and update lastConnectedUrl. */
34
+ fun addRecentUrl(url: String) {
35
+ val trimmed = url.trim()
36
+ if (trimmed.isBlank()) return
37
+ val updated = (listOf(trimmed) + recentUrls.filter { it != trimmed }).take(RECENT_URLS_MAX)
38
+ prefs.edit()
39
+ .putString(KEY_RECENT_URLS, updated.joinToString("\n"))
40
+ .putString(KEY_LAST_URL, trimmed)
41
+ .apply()
42
+ }
43
+
44
+ fun removeRecentUrl(url: String) {
45
+ val updated = recentUrls.filter { it != url }
46
+ prefs.edit().putString(KEY_RECENT_URLS, updated.joinToString("\n")).apply()
47
+ }
48
+
49
+ fun clearRecentUrls() {
50
+ prefs.edit().remove(KEY_RECENT_URLS).apply()
51
+ }
52
+
53
+ companion object {
54
+ private const val PREFS_NAME = "sigx_dev_client"
55
+ private const val KEY_PERF_HUD = "perf_hud_enabled"
56
+ private const val KEY_LOGBOX = "logbox_enabled"
57
+ private const val KEY_LAST_URL = "last_connected_url"
58
+ private const val KEY_RECENT_URLS = "recent_urls"
59
+ private const val RECENT_URLS_MAX = 20
60
+ }
61
+ }
@@ -0,0 +1,76 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.lynx.tasm.provider.AbsTemplateProvider
6
+ import java.io.ByteArrayOutputStream
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+
10
+ private const val TAG = "SigxDevClient"
11
+
12
+ /**
13
+ * Loads Lynx bundles from HTTP URLs (dev server) or assets (production).
14
+ */
15
+ class DevTemplateProvider(context: Context) : AbsTemplateProvider() {
16
+
17
+ private val appContext: Context = context.applicationContext
18
+
19
+ override fun loadTemplate(uri: String, callback: Callback) {
20
+ Log.d(TAG, "Loading template: $uri")
21
+ Thread {
22
+ try {
23
+ if (uri.startsWith("http://") || uri.startsWith("https://")) {
24
+ loadFromUrl(uri, callback)
25
+ } else {
26
+ loadFromAssets(uri, callback)
27
+ }
28
+ } catch (e: Exception) {
29
+ Log.e(TAG, "Failed to load template: $uri", e)
30
+ callback.onFailed(e.message ?: "Unknown error loading template")
31
+ }
32
+ }.start()
33
+ }
34
+
35
+ private fun loadFromUrl(urlString: String, callback: Callback) {
36
+ val url = URL(urlString)
37
+ val connection = url.openConnection() as HttpURLConnection
38
+ try {
39
+ connection.connectTimeout = 10_000
40
+ connection.readTimeout = 30_000
41
+ connection.requestMethod = "GET"
42
+
43
+ if (connection.responseCode != HttpURLConnection.HTTP_OK) {
44
+ Log.e(TAG, "Template fetch failed: HTTP ${connection.responseCode} from $urlString")
45
+ callback.onFailed("HTTP ${connection.responseCode}: ${connection.responseMessage}")
46
+ return
47
+ }
48
+
49
+ connection.inputStream.use { inputStream ->
50
+ ByteArrayOutputStream().use { output ->
51
+ val buffer = ByteArray(8192)
52
+ var length: Int
53
+ while (inputStream.read(buffer).also { length = it } != -1) {
54
+ output.write(buffer, 0, length)
55
+ }
56
+ callback.onSuccess(output.toByteArray())
57
+ }
58
+ }
59
+ } finally {
60
+ connection.disconnect()
61
+ }
62
+ }
63
+
64
+ private fun loadFromAssets(uri: String, callback: Callback) {
65
+ appContext.assets.open(uri).use { inputStream ->
66
+ ByteArrayOutputStream().use { output ->
67
+ val buffer = ByteArray(8192)
68
+ var length: Int
69
+ while (inputStream.read(buffer).also { length = it } != -1) {
70
+ output.write(buffer, 0, length)
71
+ }
72
+ callback.onSuccess(output.toByteArray())
73
+ }
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,113 @@
1
+ package com.sigx.devclient
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import com.lynx.tasm.resourceprovider.LynxResourceCallback
6
+ import com.lynx.tasm.resourceprovider.LynxResourceRequest
7
+ import com.lynx.tasm.resourceprovider.LynxResourceResponse
8
+ import com.lynx.tasm.resourceprovider.template.LynxTemplateResourceFetcher
9
+ import com.lynx.tasm.resourceprovider.template.TemplateProviderResult
10
+ import okhttp3.Call
11
+ import okhttp3.Callback
12
+ import okhttp3.OkHttpClient
13
+ import okhttp3.Request
14
+ import okhttp3.Response
15
+ import java.io.IOException
16
+ import java.util.concurrent.TimeUnit
17
+
18
+ /**
19
+ * Handles template and HMR hot-update fetching for LynxView.
20
+ * Required for HMR to work — the AbsTemplateProvider only handles initial loads.
21
+ */
22
+ @Suppress("UNCHECKED_CAST")
23
+ class DevTemplateResourceFetcher(context: Context) : LynxTemplateResourceFetcher() {
24
+
25
+ private val appContext: Context = context.applicationContext
26
+
27
+ private val httpClient = OkHttpClient.Builder()
28
+ .connectTimeout(10, TimeUnit.SECONDS)
29
+ .readTimeout(30, TimeUnit.SECONDS)
30
+ .build()
31
+
32
+ override fun fetchTemplate(
33
+ request: LynxResourceRequest,
34
+ callback: LynxResourceCallback<TemplateProviderResult>
35
+ ) {
36
+ if (request == null) {
37
+ callback.onResponse(failed("request is null"))
38
+ return
39
+ }
40
+
41
+ val url = request.url
42
+ if (url.startsWith("file://") || (!url.startsWith("http://") && !url.startsWith("https://"))) {
43
+ fetchFromAssets(url.removePrefix("file://"), callback)
44
+ return
45
+ }
46
+
47
+ val httpRequest = Request.Builder().url(url).build()
48
+ httpClient.newCall(httpRequest).enqueue(object : Callback {
49
+ override fun onResponse(call: Call, response: Response) {
50
+ response.use {
51
+ if (it.isSuccessful && it.body != null) {
52
+ val bytes = it.body!!.bytes()
53
+ val result = TemplateProviderResult.fromBinary(bytes)
54
+ callback.onResponse(LynxResourceResponse.onSuccess(result))
55
+ } else {
56
+ callback.onResponse(failed("HTTP ${it.code}: ${it.message}"))
57
+ }
58
+ }
59
+ }
60
+
61
+ override fun onFailure(call: Call, e: IOException) {
62
+ Log.e("SigxDevClient", "Template resource fetch failed: ${request.url}", e)
63
+ callback.onResponse(failed(e))
64
+ }
65
+ })
66
+ }
67
+
68
+ override fun fetchSSRData(
69
+ request: LynxResourceRequest,
70
+ callback: LynxResourceCallback<ByteArray>
71
+ ) {
72
+ if (request == null) {
73
+ callback.onResponse(failed("request is null"))
74
+ return
75
+ }
76
+
77
+ val httpRequest = Request.Builder().url(request.url).build()
78
+ httpClient.newCall(httpRequest).enqueue(object : Callback {
79
+ override fun onResponse(call: Call, response: Response) {
80
+ response.use {
81
+ if (it.isSuccessful && it.body != null) {
82
+ callback.onResponse(LynxResourceResponse.onSuccess(it.body!!.bytes()))
83
+ } else {
84
+ callback.onResponse(failed("HTTP ${it.code}: ${it.message}"))
85
+ }
86
+ }
87
+ }
88
+
89
+ override fun onFailure(call: Call, e: IOException) {
90
+ callback.onResponse(failed(e))
91
+ }
92
+ })
93
+ }
94
+
95
+ private fun fetchFromAssets(
96
+ path: String,
97
+ callback: LynxResourceCallback<TemplateProviderResult>
98
+ ) {
99
+ try {
100
+ val bytes = appContext.assets.open(path).use { it.readBytes() }
101
+ val result = TemplateProviderResult.fromBinary(bytes)
102
+ callback.onResponse(LynxResourceResponse.onSuccess(result))
103
+ } catch (e: Exception) {
104
+ callback.onResponse(failed(e))
105
+ }
106
+ }
107
+
108
+ private fun <T> failed(message: String): LynxResourceResponse<T> =
109
+ LynxResourceResponse.onFailed(Throwable(message)) as LynxResourceResponse<T>
110
+
111
+ private fun <T> failed(e: Throwable): LynxResourceResponse<T> =
112
+ LynxResourceResponse.onFailed(e) as LynxResourceResponse<T>
113
+ }