@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.
- package/LICENSE +21 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevGenericResourceFetcher.kt +73 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevHomeScreen.kt +163 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevLynxScreen.kt +208 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevMenu.kt +278 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevQRScanner.kt +174 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevSettings.kt +61 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevTemplateProvider.kt +76 -0
- package/android/src/main/kotlin/com/sigx/devclient/DevTemplateResourceFetcher.kt +113 -0
- package/android/src/main/kotlin/com/sigx/devclient/ErrorOverlay.kt +86 -0
- package/android/src/main/kotlin/com/sigx/devclient/PerfHud.kt +100 -0
- package/android/src/main/kotlin/com/sigx/devclient/ShakeDetector.kt +97 -0
- package/android/src/main/kotlin/com/sigx/devclient/SigxDevClient.kt +91 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/ios/Sources/SigxDevClient/DevGenericResourceFetcher.swift +47 -0
- package/ios/Sources/SigxDevClient/DevHomeScreen.swift +128 -0
- package/ios/Sources/SigxDevClient/DevMenuView.swift +133 -0
- package/ios/Sources/SigxDevClient/DevQRScanner.swift +165 -0
- package/ios/Sources/SigxDevClient/DevTemplateProvider.swift +68 -0
- package/ios/Sources/SigxDevClient/DevTemplateResourceFetcher.swift +73 -0
- package/ios/Sources/SigxDevClient/ShakeDetector.swift +60 -0
- package/ios/Sources/SigxDevClient/SigxDevClient.swift +106 -0
- 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
|
+
}
|