@craft-native/android 0.0.158 → 0.0.161

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/README.md CHANGED
@@ -96,10 +96,16 @@ stx native compile --config ../screens/native.config.json --format bundle --outp
96
96
  craft android build --native-bundle ../native-screen.js
97
97
  ```
98
98
 
99
- Native mode maps STX `View`, `SafeAreaView`, `Text`, `Button`, `Link`,
100
- `Pressable`, `TouchableOpacity`, `TouchableHighlight`, `TextInput`, `Picker`, `Modal`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `Video`, `ScrollView`,
101
- and `FlatList` to Android views. `Picker` renders `<select>`/`<option>` values
102
- with `onValueChange` or `onChange`. `Modal` is parent-scoped and supports
99
+ Native mode maps STX `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `Icon`,
100
+ `Pressable`, `TouchableOpacity`, `TouchableHighlight`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Modal`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `Video`, `ScrollView`,
101
+ `SectionList`, and `FlatList` to Android views. `SectionList` uses the same
102
+ native scroll host for ordinary `.stx` slot children; grouped section data
103
+ should use `FlatList` until the section-data protocol is available. The
104
+ generated host routes both list names through `CraftNativeScrollView` for this
105
+ slot-based contract. `Picker` renders `<select>`/`<option>` values
106
+ with `onValueChange` or `onChange`. `DatePicker` uses the platform `DatePicker`
107
+ and `TimePicker` controls for date, time, datetime, and countdown modes and
108
+ emits local `value`, ISO, and millisecond `timestamp` fields. `Modal` is parent-scoped and supports
103
109
  `visible`, `transparent`, `animationType`, `onShow`, `onDismiss`, and
104
110
  `onRequestClose` for Android back handling. `Switch` supports state-aware `trackColor`
105
111
  and `thumbColor`, while `Slider` supports track/thumb tint props and `step`
@@ -108,6 +114,16 @@ non-animated when `animating={false}` and `hidesWhenStopped={false}`. `Image`
108
114
  reports load lifecycle events and structured failures. `Video` uses the native
109
115
  `VideoView` for URI playback with autoplay, pause, looping, mute, controls, and
110
116
  resize-mode props.
117
+ `ImageBackground` loads an image behind ordinary native children while
118
+ preserving the shared flex layout contract.
119
+ `Icon` uses the shared symbol/name contract with a dependency-free Android
120
+ glyph map; unknown symbols render a hollow-circle fallback instead of an empty
121
+ native node.
122
+ `KeyboardAvoidingView` responds to Android IME insets with `padding`,
123
+ `position`, or `height` behavior and accepts `keyboardVerticalOffset`.
124
+ `Checkbox` accepts controlled `checked`/`value` and `indeterminate`; mixed
125
+ controls expose an Android accessibility state description of `mixed` and
126
+ become checked on their first user toggle.
111
127
  `FlatList` uses
112
128
  `RecyclerView`, stable IDs, and `DiffUtil` for keyed recycling, grids,
113
129
  horizontal and inverted layouts, list chrome, and end-reached pagination.
@@ -142,6 +158,17 @@ after inset or orientation changes. The shared component, capability, list, styl
142
158
  image-source, event, accessibility, and reconciliation contract is documented in the
143
159
  [Native STX guide](../../docs/guides/native-stx.md).
144
160
 
161
+ Native screens also expose `craft.files` when the host advertises the
162
+ `files` capability. `pickImage` returns an app-owned file descriptor,
163
+ `download` and `write` keep bytes on disk, and `remove` accepts only files
164
+ created by that capability. `FormData` is available on this path; `fetch`
165
+ builds its multipart body natively from text fields and those owned files, so
166
+ image bytes do not cross the Android JavaScript bridge as base64. The browser
167
+ renderer keeps using the ordinary web `File`/`FormData` implementations. Owned
168
+ files survive Activity recreation and stale cache entries are swept after a day.
169
+ `files.download` resolves a non-2xx response as `{ ok: false, status, body }`
170
+ instead of turning the server's response into a transport error.
171
+
145
172
  `FlatList` item roots receive the same `onLayout` callback as ordinary native
146
173
  views when rows are materialized or recycled.
147
174
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.158",
3
+ "version": "0.0.161",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -280,6 +280,61 @@ object CraftNative {
280
280
  margins: FloatArray,
281
281
  flags: FloatArray,
282
282
  ): FloatArray?
283
+ private external fun nativeGridAutoRows(
284
+ availableWidth: Float,
285
+ availableHeight: Float,
286
+ columns: Int,
287
+ columnGap: Float,
288
+ rowGap: Float,
289
+ autoRowHeight: Float,
290
+ intrinsicWidth: FloatArray,
291
+ intrinsicHeight: FloatArray,
292
+ explicitWidth: FloatArray,
293
+ explicitHeight: FloatArray,
294
+ minWidth: FloatArray,
295
+ maxWidth: FloatArray,
296
+ minHeight: FloatArray,
297
+ maxHeight: FloatArray,
298
+ margins: FloatArray,
299
+ flags: FloatArray,
300
+ ): FloatArray?
301
+ private external fun nativeGridAligned(
302
+ availableWidth: Float,
303
+ availableHeight: Float,
304
+ columns: Int,
305
+ columnGap: Float,
306
+ rowGap: Float,
307
+ alignContent: Int,
308
+ intrinsicWidth: FloatArray,
309
+ intrinsicHeight: FloatArray,
310
+ explicitWidth: FloatArray,
311
+ explicitHeight: FloatArray,
312
+ minWidth: FloatArray,
313
+ maxWidth: FloatArray,
314
+ minHeight: FloatArray,
315
+ maxHeight: FloatArray,
316
+ margins: FloatArray,
317
+ flags: FloatArray,
318
+ ): FloatArray?
319
+ private external fun nativeGridAutoRowsAligned(
320
+ availableWidth: Float,
321
+ availableHeight: Float,
322
+ columns: Int,
323
+ columnGap: Float,
324
+ rowGap: Float,
325
+ alignContent: Int,
326
+ autoRowHeight: Float,
327
+ intrinsicWidth: FloatArray,
328
+ intrinsicHeight: FloatArray,
329
+ explicitWidth: FloatArray,
330
+ explicitHeight: FloatArray,
331
+ minWidth: FloatArray,
332
+ maxWidth: FloatArray,
333
+ minHeight: FloatArray,
334
+ maxHeight: FloatArray,
335
+ margins: FloatArray,
336
+ flags: FloatArray,
337
+ ): FloatArray?
283
338
  private external fun nativeClipboardRead(activity: Activity): String?
284
339
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
285
340
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -513,10 +568,16 @@ object CraftNative {
513
568
  maxHeight: FloatArray,
514
569
  margins: FloatArray,
515
570
  flags: FloatArray,
571
+ autoRowHeight: Float? = null,
572
+ alignContent: Int = 0,
516
573
  ): FloatArray? {
517
574
  if (!isAvailable) return null
518
575
  return try {
519
- nativeGrid(availableWidth, availableHeight, columns, columnGap, rowGap, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
576
+ if (autoRowHeight != null) {
577
+ nativeGridAutoRowsAligned(availableWidth, availableHeight, columns, columnGap, rowGap, alignContent, autoRowHeight, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
578
+ } else {
579
+ nativeGridAligned(availableWidth, availableHeight, columns, columnGap, rowGap, alignContent, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
580
+ }
520
581
  ?.takeIf { it.size >= intrinsicWidth.size * 4 && it.all { value -> value.isFinite() } }
521
582
  } catch (e: UnsatisfiedLinkError) {
522
583
  null
@@ -12,8 +12,11 @@ import android.database.sqlite.SQLiteCursor
12
12
  import android.database.sqlite.SQLiteDatabase
13
13
  import android.database.sqlite.SQLiteQuery
14
14
  import android.database.sqlite.SQLiteStatement
15
+ import android.graphics.Bitmap
16
+ import android.graphics.BitmapFactory
15
17
  import android.os.Build
16
18
  import android.net.Uri
19
+ import android.provider.MediaStore
17
20
  import android.util.Base64
18
21
  import androidx.core.content.ContextCompat
19
22
  import androidx.biometric.BiometricManager
@@ -25,15 +28,25 @@ import org.json.JSONArray
25
28
  import org.json.JSONObject
26
29
  import org.json.JSONTokener
27
30
  import java.io.ByteArrayOutputStream
31
+ import java.io.File
32
+ import java.io.FileOutputStream
28
33
  import java.net.HttpURLConnection
29
34
  import java.net.SocketTimeoutException
30
35
  import java.net.URL
36
+ import java.net.URLConnection
31
37
  import java.util.UUID
32
38
  import java.util.concurrent.ConcurrentHashMap
33
39
  import java.util.concurrent.Executors
40
+ import kotlin.math.max
41
+ import kotlin.math.roundToInt
34
42
 
35
43
  private const val CRAFT_NATIVE_FETCH_MAX_RESPONSE_BYTES = 8 * 1024 * 1024
36
44
  private const val CRAFT_NATIVE_FETCH_TIMEOUT_MS = 30_000
45
+ private const val CRAFT_NATIVE_FILE_PICK_REQUEST = 4101
46
+ private const val CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048
47
+ private const val CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS = 8192
48
+ private const val CRAFT_NATIVE_FILE_DEFAULT_QUALITY = 0.85
49
+ private const val CRAFT_NATIVE_FILE_ERROR_BODY_BYTES = 64 * 1024
37
50
 
38
51
  internal const val CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION = 1
39
52
 
@@ -68,13 +81,26 @@ internal class CraftNativeCapabilities(
68
81
  private val networkExecutor = Executors.newCachedThreadPool()
69
82
  private val cancelled = ConcurrentHashMap.newKeySet<String>()
70
83
  private val fetchConnections = ConcurrentHashMap<String, HttpURLConnection>()
84
+ private val fileRoot by lazy { File(activity.cacheDir, "craft-files").apply { mkdirs() } }
85
+ private var pendingFilePick: PendingFilePick? = null
71
86
  private var database: SQLiteDatabase? = null
72
87
  private val biometricPrompts = ConcurrentHashMap<String, BiometricPrompt>()
73
88
 
74
- init { restoreNotifications() }
89
+ private data class PendingFilePick(
90
+ val requestToken: String,
91
+ val source: String,
92
+ val maxPixels: Int,
93
+ val quality: Float,
94
+ val completion: (CraftNativeCapabilityResult) -> Unit,
95
+ )
96
+
97
+ init {
98
+ restoreNotifications()
99
+ sweepFiles()
100
+ }
75
101
 
76
102
  fun names(): JSONArray = JSONArray().apply {
77
- listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch").forEach(::put)
103
+ listOf("device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "files").forEach(::put)
78
104
  if (config.optBoolean("enableBiometric")) put("biometric")
79
105
  if (config.optBoolean("enableSecureStorage")) put("secureStorage")
80
106
  if (config.optBoolean("enableLocalDatabase")) put("database")
@@ -121,6 +147,13 @@ internal class CraftNativeCapabilities(
121
147
  complete(requestToken, fetch(requestToken, args), completion)
122
148
  }
123
149
  }
150
+ "Files" -> when (method) {
151
+ "pickImage" -> pickImage(requestToken, args, completion)
152
+ "download", "write", "remove" -> networkExecutor.execute {
153
+ complete(requestToken, files(requestToken, method, args), completion)
154
+ }
155
+ else -> complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Files method"), completion)
156
+ }
124
157
  else -> complete(requestToken, activity.performImmediateCapability(module, method, args), completion)
125
158
  }
126
159
  }
@@ -128,6 +161,7 @@ internal class CraftNativeCapabilities(
128
161
  fun cancel(requestToken: String) {
129
162
  cancelled.add(requestToken)
130
163
  fetchConnections.remove(requestToken)?.disconnect()
164
+ if (pendingFilePick?.requestToken == requestToken) pendingFilePick = null
131
165
  biometricPrompts.remove(requestToken)?.cancelAuthentication()
132
166
  }
133
167
 
@@ -136,6 +170,7 @@ internal class CraftNativeCapabilities(
136
170
  networkExecutor.shutdownNow()
137
171
  fetchConnections.values.forEach(HttpURLConnection::disconnect)
138
172
  fetchConnections.clear()
173
+ pendingFilePick = null
139
174
  database?.close()
140
175
  database = null
141
176
  biometricPrompts.values.forEach { it.cancelAuthentication() }
@@ -155,15 +190,21 @@ internal class CraftNativeCapabilities(
155
190
  val method = options.optString("method", "GET").uppercase()
156
191
  if (method.isBlank() || method.any { !it.isLetter() || it.code > 127 })
157
192
  return failure("INVALID_ARGUMENT", "fetch method must be an HTTP token")
193
+ val form = options.optJSONArray("form")
158
194
  val bodyValue = options.opt("body")
195
+ if (form != null && bodyValue != null && bodyValue != JSONObject.NULL)
196
+ return failure("INVALID_ARGUMENT", "fetch cannot have both form and body")
159
197
  val body = when (bodyValue) {
160
198
  null, JSONObject.NULL -> null
161
199
  is String -> bodyValue
162
200
  else -> return failure("INVALID_ARGUMENT", "fetch body must be a string")
163
201
  }
164
- if (body != null && method in setOf("GET", "HEAD"))
202
+ if ((body != null || form != null) && method in setOf("GET", "HEAD"))
165
203
  return failure("INVALID_ARGUMENT", "A GET or HEAD request cannot have a body")
166
204
 
205
+ val multipart = form?.let { multipart(it) }
206
+ if (multipart is FileResult.Failure) return failure(multipart.code, multipart.message)
207
+
167
208
  val connection = try {
168
209
  (url.openConnection() as HttpURLConnection).apply {
169
210
  requestMethod = method
@@ -177,7 +218,8 @@ internal class CraftNativeCapabilities(
177
218
  setRequestProperty(name, value)
178
219
  }
179
220
  }
180
- if (body != null) doOutput = true
221
+ if (body != null || multipart is FileResult.Multipart) doOutput = true
222
+ if (multipart is FileResult.Multipart) setRequestProperty("Content-Type", "multipart/form-data; boundary=${multipart.boundary}")
181
223
  }
182
224
  } catch (error: IllegalArgumentException) {
183
225
  return failure("INVALID_ARGUMENT", error.message ?: "fetch request is invalid")
@@ -191,6 +233,7 @@ internal class CraftNativeCapabilities(
191
233
  }
192
234
  return try {
193
235
  if (body != null) connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
236
+ if (multipart is FileResult.Multipart) connection.outputStream.use { it.write(multipart.body) }
194
237
  val status = connection.responseCode
195
238
  val input = if (status >= 400) connection.errorStream else connection.inputStream
196
239
  val output = ByteArrayOutputStream()
@@ -224,6 +267,270 @@ internal class CraftNativeCapabilities(
224
267
  }
225
268
  }
226
269
 
270
+ private sealed interface FileResult {
271
+ data class Multipart(val body: ByteArray, val boundary: String) : FileResult
272
+ data class Failure(val code: String, val message: String) : FileResult
273
+ }
274
+
275
+ private fun multipart(parts: JSONArray): FileResult {
276
+ val boundary = "craft-${UUID.randomUUID()}"
277
+ val body = ByteArrayOutputStream()
278
+ fun append(value: String) { body.write(value.toByteArray(Charsets.UTF_8)) }
279
+ for (index in 0 until parts.length()) {
280
+ val part = parts.optJSONObject(index) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form part must be an object")
281
+ val name = part.optString("name")
282
+ if (name.isBlank()) return FileResult.Failure("INVALID_ARGUMENT", "A form part needs a name")
283
+ append("--$boundary\r\n")
284
+ val path = part.optString("path")
285
+ if (path.isNotBlank()) {
286
+ val file = ownedFile(path) ?: return FileResult.Failure("INVALID_ARGUMENT", "A form file must be one craft.files made, and still there")
287
+ val bytes = runCatching { file.readBytes() }.getOrElse {
288
+ return FileResult.Failure("INVALID_ARGUMENT", "A form file could not be read")
289
+ }
290
+ val filename = safeFileName(part.optString("filename").ifBlank { file.name })
291
+ val type = part.optString("type").ifBlank { URLConnection.guessContentTypeFromName(filename) ?: "application/octet-stream" }
292
+ append("Content-Disposition: form-data; name=\"${quoted(name)}\"; filename=\"${quoted(filename)}\"\r\n")
293
+ append("Content-Type: ${headerValue(type)}\r\n\r\n")
294
+ body.write(bytes)
295
+ append("\r\n")
296
+ } else {
297
+ append("Content-Disposition: form-data; name=\"${quoted(name)}\"\r\n\r\n")
298
+ append(part.optString("value"))
299
+ append("\r\n")
300
+ }
301
+ }
302
+ append("--$boundary--\r\n")
303
+ return FileResult.Multipart(body.toByteArray(), boundary)
304
+ }
305
+
306
+ private fun files(requestToken: String, method: String, args: JSONArray): CraftNativeCapabilityResult {
307
+ return when (method) {
308
+ "write" -> {
309
+ val name = args.optString(0).trim()
310
+ if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
311
+ val data = args.optString(1, "")
312
+ val options = args.optJSONObject(2) ?: JSONObject()
313
+ val bytes = when (options.optString("encoding", "utf8")) {
314
+ "utf8" -> data.toByteArray(Charsets.UTF_8)
315
+ "base64" -> runCatching { Base64.decode(data, Base64.DEFAULT) }
316
+ .getOrElse { return failure("INVALID_ARGUMENT", "files.write base64 is invalid") }
317
+ else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
318
+ }
319
+ store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
320
+ }
321
+ "remove" -> {
322
+ val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
323
+ success(file.delete())
324
+ }
325
+ "download" -> download(requestToken, args)
326
+ else -> failure("UNKNOWN_ACTION", "Unsupported Files method")
327
+ }
328
+ }
329
+
330
+ private fun download(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
331
+ val address = args.optString(0).trim()
332
+ val url = validURL(address) ?: return failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL")
333
+ val options = args.optJSONObject(1) ?: JSONObject()
334
+ val connection = try {
335
+ (url.openConnection() as HttpURLConnection).apply {
336
+ requestMethod = "GET"
337
+ connectTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
338
+ readTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
339
+ options.optJSONObject("headers")?.let { headers ->
340
+ for (name in headers.keys()) {
341
+ val value = headers.opt(name)
342
+ if (value !is String) throw IllegalArgumentException("files.download header values must be strings")
343
+ setRequestProperty(name, value)
344
+ }
345
+ }
346
+ }
347
+ } catch (error: IllegalArgumentException) {
348
+ return failure("INVALID_ARGUMENT", error.message ?: "files.download request is invalid")
349
+ } catch (error: Exception) {
350
+ return failure("NETWORK_ERROR", error.message ?: "File download failed")
351
+ }
352
+ fetchConnections[requestToken] = connection
353
+ if (cancelled.contains(requestToken)) {
354
+ fetchConnections.remove(requestToken)?.disconnect()
355
+ return failure("CANCELLED", "File download was cancelled")
356
+ }
357
+ return try {
358
+ val status = connection.responseCode
359
+ if (status !in 200..299) return refusedDownload(connection, status, requestToken)
360
+ val name = options.optString("name").ifBlank { connection.url.path.substringAfterLast('/').ifBlank { "download" } }
361
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
362
+ connection.inputStream.use { input -> FileOutputStream(file).use { output ->
363
+ val buffer = ByteArray(8192)
364
+ while (true) {
365
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
366
+ val count = input.read(buffer)
367
+ if (count < 0) break
368
+ output.write(buffer, 0, count)
369
+ }
370
+ } }
371
+ success(descriptor(file, connection.contentType ?: "application/octet-stream"))
372
+ } catch (error: SocketTimeoutException) {
373
+ failure("TIMEOUT", error.message ?: "File download timed out")
374
+ } catch (error: Exception) {
375
+ if (cancelled.contains(requestToken)) failure("CANCELLED", "File download was cancelled")
376
+ else failure("NETWORK_ERROR", error.message ?: "File download failed")
377
+ } finally {
378
+ fetchConnections.remove(requestToken)?.disconnect()
379
+ connection.disconnect()
380
+ }
381
+ }
382
+
383
+ private fun refusedDownload(
384
+ connection: HttpURLConnection,
385
+ status: Int,
386
+ requestToken: String,
387
+ ): CraftNativeCapabilityResult {
388
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
389
+ val body = ByteArrayOutputStream()
390
+ connection.errorStream?.use { input ->
391
+ val buffer = ByteArray(8192)
392
+ while (body.size() < CRAFT_NATIVE_FILE_ERROR_BODY_BYTES) {
393
+ val count = input.read(buffer, 0, minOf(buffer.size, CRAFT_NATIVE_FILE_ERROR_BODY_BYTES - body.size()))
394
+ if (count < 0) break
395
+ body.write(buffer, 0, count)
396
+ }
397
+ }
398
+ return success(JSONObject().put("ok", false).put("status", status).put("body", body.toString(Charsets.UTF_8.name())))
399
+ }
400
+
401
+ private fun pickImage(
402
+ requestToken: String,
403
+ args: JSONArray,
404
+ completion: (CraftNativeCapabilityResult) -> Unit,
405
+ ) {
406
+ activity.runOnUiThread {
407
+ if (pendingFilePick != null) {
408
+ complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
409
+ return@runOnUiThread
410
+ }
411
+ val options = args.optJSONObject(0) ?: JSONObject()
412
+ val source = options.optString("source", "library")
413
+ if (source != "library" && source != "camera") {
414
+ complete(requestToken, failure("INVALID_ARGUMENT", "files.pickImage source is 'library' or 'camera'"), completion)
415
+ return@runOnUiThread
416
+ }
417
+ val maxPixels = options.optInt("maxPixels", CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS)
418
+ .coerceIn(64, CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS)
419
+ val quality = options.optDouble("quality", CRAFT_NATIVE_FILE_DEFAULT_QUALITY)
420
+ .coerceIn(0.1, 1.0).toFloat()
421
+ if (source == "camera" && !config.optBoolean("enableCamera")) {
422
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Camera is disabled"), completion)
423
+ return@runOnUiThread
424
+ }
425
+ pendingFilePick = PendingFilePick(requestToken, source, maxPixels, quality, completion)
426
+ val intent = if (source == "camera") Intent(MediaStore.ACTION_IMAGE_CAPTURE)
427
+ else Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
428
+ addCategory(Intent.CATEGORY_OPENABLE)
429
+ type = "image/*"
430
+ }
431
+ try {
432
+ activity.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
433
+ } catch (error: Exception) {
434
+ pendingFilePick = null
435
+ complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Image picker could not be opened"), completion)
436
+ }
437
+ }
438
+ }
439
+
440
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
441
+ if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
442
+ val pending = pendingFilePick ?: return true
443
+ pendingFilePick = null
444
+ if (resultCode != android.app.Activity.RESULT_OK) {
445
+ complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
446
+ return true
447
+ }
448
+ val descriptor = if (pending.source == "camera") {
449
+ val bitmap = data?.extras?.get("data") as? Bitmap
450
+ bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
451
+ } else {
452
+ val uri = data?.data
453
+ uri?.let { copyUri(it, pending.maxPixels, pending.quality) }
454
+ }
455
+ complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion)
456
+ return true
457
+ }
458
+
459
+ private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
460
+ val name = activity.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
461
+ if (it.moveToFirst()) it.getString(0) else null
462
+ } ?: "photo"
463
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
464
+ activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } ?: return null
465
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
466
+ var sample = 1
467
+ while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
468
+ val options = BitmapFactory.Options().apply { inSampleSize = sample }
469
+ val bitmap = activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
470
+ return writeImage(bitmap, maxPixels, quality, jpegName(name))
471
+ }
472
+
473
+ private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
474
+ val longest = max(bitmap.width, bitmap.height)
475
+ val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f
476
+ val output = if (scale < 1f) Bitmap.createScaledBitmap(
477
+ bitmap,
478
+ (bitmap.width * scale).roundToInt().coerceAtLeast(1),
479
+ (bitmap.height * scale).roundToInt().coerceAtLeast(1),
480
+ true,
481
+ ) else bitmap
482
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
483
+ val width = output.width
484
+ val height = output.height
485
+ val written = runCatching {
486
+ FileOutputStream(file).use { output.compress(Bitmap.CompressFormat.JPEG, (quality * 100).roundToInt(), it) }
487
+ }.getOrDefault(false)
488
+ if (output !== bitmap) output.recycle()
489
+ bitmap.recycle()
490
+ return if (written) descriptor(file, "image/jpeg").put("width", width).put("height", height) else null
491
+ }
492
+
493
+ private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult {
494
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
495
+ return runCatching { file.writeBytes(bytes); success(descriptor(file, type)) }
496
+ .getOrElse { failure("WRITE_ERROR", it.message ?: "File could not be written") }
497
+ }
498
+
499
+ private fun descriptor(file: File, type: String): JSONObject = JSONObject()
500
+ .put("path", file.absolutePath)
501
+ .put("name", file.name)
502
+ .put("type", type)
503
+ .put("size", file.length())
504
+
505
+ private fun sweepFiles() {
506
+ val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
507
+ fileRoot.listFiles()?.forEach { file ->
508
+ if (file.lastModified() < cutoff) file.deleteRecursively()
509
+ }
510
+ }
511
+
512
+ private fun ownedFile(path: String): File? {
513
+ if (path.isBlank()) return null
514
+ val root = runCatching { fileRoot.canonicalFile }.getOrNull() ?: return null
515
+ val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
516
+ return file.takeIf { it.path.startsWith(root.path + File.separator) }
517
+ }
518
+
519
+ private fun safeFileName(raw: String): String {
520
+ val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
521
+ return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
522
+ }
523
+
524
+ private fun jpegName(raw: String): String {
525
+ val safe = safeFileName(raw)
526
+ val base = safe.substringBeforeLast('.', safe).ifBlank { "photo" }
527
+ return "$base.jpg"
528
+ }
529
+
530
+ private fun quoted(value: String): String = value.replace("\\", "%5C").replace("\"", "%22").replace("\r", "%0D").replace("\n", "%0A")
531
+ private fun headerValue(value: String): String = value.replace("\r", "").replace("\n", "")
532
+ private fun validURL(raw: String): URL? = runCatching { URL(raw) }.getOrNull()?.takeIf { it.protocol.lowercase() in setOf("http", "https") && it.host.isNotBlank() }
533
+
227
534
  private fun complete(
228
535
  requestToken: String,
229
536
  result: CraftNativeCapabilityResult,