@craft-native/android 0.0.156 → 0.0.160

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,12 @@ 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`,
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
101
  and `FlatList` to Android views. `Picker` renders `<select>`/`<option>` values
102
- with `onValueChange` or `onChange`. `Modal` is parent-scoped and supports
102
+ with `onValueChange` or `onChange`. `DatePicker` uses the platform `DatePicker`
103
+ and `TimePicker` controls for date, time, datetime, and countdown modes and
104
+ emits local `value`, ISO, and millisecond `timestamp` fields. `Modal` is parent-scoped and supports
103
105
  `visible`, `transparent`, `animationType`, `onShow`, `onDismiss`, and
104
106
  `onRequestClose` for Android back handling. `Switch` supports state-aware `trackColor`
105
107
  and `thumbColor`, while `Slider` supports track/thumb tint props and `step`
@@ -108,6 +110,16 @@ non-animated when `animating={false}` and `hidesWhenStopped={false}`. `Image`
108
110
  reports load lifecycle events and structured failures. `Video` uses the native
109
111
  `VideoView` for URI playback with autoplay, pause, looping, mute, controls, and
110
112
  resize-mode props.
113
+ `ImageBackground` loads an image behind ordinary native children while
114
+ preserving the shared flex layout contract.
115
+ `Icon` uses the shared symbol/name contract with a dependency-free Android
116
+ glyph map; unknown symbols render a hollow-circle fallback instead of an empty
117
+ native node.
118
+ `KeyboardAvoidingView` responds to Android IME insets with `padding`,
119
+ `position`, or `height` behavior and accepts `keyboardVerticalOffset`.
120
+ `Checkbox` accepts controlled `checked`/`value` and `indeterminate`; mixed
121
+ controls expose an Android accessibility state description of `mixed` and
122
+ become checked on their first user toggle.
111
123
  `FlatList` uses
112
124
  `RecyclerView`, stable IDs, and `DiffUtil` for keyed recycling, grids,
113
125
  horizontal and inverted layouts, list chrome, and end-reached pagination.
@@ -142,6 +154,17 @@ after inset or orientation changes. The shared component, capability, list, styl
142
154
  image-source, event, accessibility, and reconciliation contract is documented in the
143
155
  [Native STX guide](../../docs/guides/native-stx.md).
144
156
 
157
+ Native screens also expose `craft.files` when the host advertises the
158
+ `files` capability. `pickImage` returns an app-owned file descriptor,
159
+ `download` and `write` keep bytes on disk, and `remove` accepts only files
160
+ created by that capability. `FormData` is available on this path; `fetch`
161
+ builds its multipart body natively from text fields and those owned files, so
162
+ image bytes do not cross the Android JavaScript bridge as base64. The browser
163
+ renderer keeps using the ordinary web `File`/`FormData` implementations. Owned
164
+ files survive Activity recreation and stale cache entries are swept after a day.
165
+ `files.download` resolves a non-2xx response as `{ ok: false, status, body }`
166
+ instead of turning the server's response into a transport error.
167
+
145
168
  `FlatList` item roots receive the same `onLayout` callback as ordinary native
146
169
  views when rows are materialized or recycled.
147
170
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.156",
3
+ "version": "0.0.160",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -280,6 +280,24 @@ 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?
283
301
  private external fun nativeClipboardRead(activity: Activity): String?
284
302
  private external fun nativeClipboardWrite(activity: Activity, text: String): Boolean
285
303
  private external fun nativeOpenUrl(activity: Activity, url: String): Boolean
@@ -513,10 +531,15 @@ object CraftNative {
513
531
  maxHeight: FloatArray,
514
532
  margins: FloatArray,
515
533
  flags: FloatArray,
534
+ autoRowHeight: Float? = null,
516
535
  ): FloatArray? {
517
536
  if (!isAvailable) return null
518
537
  return try {
519
- nativeGrid(availableWidth, availableHeight, columns, columnGap, rowGap, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
538
+ if (autoRowHeight != null) {
539
+ nativeGridAutoRows(availableWidth, availableHeight, columns, columnGap, rowGap, autoRowHeight, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
540
+ } else {
541
+ nativeGrid(availableWidth, availableHeight, columns, columnGap, rowGap, intrinsicWidth, intrinsicHeight, explicitWidth, explicitHeight, minWidth, maxWidth, minHeight, maxHeight, margins, flags)
542
+ }
520
543
  ?.takeIf { it.size >= intrinsicWidth.size * 4 && it.all { value -> value.isFinite() } }
521
544
  } catch (e: UnsatisfiedLinkError) {
522
545
  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,268 @@ 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 = when (method) {
307
+ "write" -> {
308
+ val name = args.optString(0).trim()
309
+ if (name.isBlank()) return failure("INVALID_ARGUMENT", "files.write needs a file name")
310
+ val data = args.optString(1, "")
311
+ val options = args.optJSONObject(2) ?: JSONObject()
312
+ val bytes = when (options.optString("encoding", "utf8")) {
313
+ "utf8" -> data.toByteArray(Charsets.UTF_8)
314
+ "base64" -> runCatching { Base64.decode(data, Base64.DEFAULT) }
315
+ .getOrElse { return failure("INVALID_ARGUMENT", "files.write base64 is invalid") }
316
+ else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
317
+ }
318
+ store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
319
+ }
320
+ "remove" -> {
321
+ val file = ownedFile(args.optString(0)) ?: return failure("INVALID_ARGUMENT", "files.remove needs a file made by craft.files")
322
+ success(file.delete())
323
+ }
324
+ "download" -> download(requestToken, args)
325
+ else -> failure("UNKNOWN_ACTION", "Unsupported Files method")
326
+ }
327
+
328
+ private fun download(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
329
+ val address = args.optString(0).trim()
330
+ val url = validURL(address) ?: return failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL")
331
+ val options = args.optJSONObject(1) ?: JSONObject()
332
+ val connection = try {
333
+ (url.openConnection() as HttpURLConnection).apply {
334
+ requestMethod = "GET"
335
+ connectTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
336
+ readTimeout = CRAFT_NATIVE_FETCH_TIMEOUT_MS
337
+ options.optJSONObject("headers")?.let { headers ->
338
+ for (name in headers.keys()) {
339
+ val value = headers.opt(name)
340
+ if (value !is String) throw IllegalArgumentException("files.download header values must be strings")
341
+ setRequestProperty(name, value)
342
+ }
343
+ }
344
+ }
345
+ } catch (error: IllegalArgumentException) {
346
+ return failure("INVALID_ARGUMENT", error.message ?: "files.download request is invalid")
347
+ } catch (error: Exception) {
348
+ return failure("NETWORK_ERROR", error.message ?: "File download failed")
349
+ }
350
+ fetchConnections[requestToken] = connection
351
+ if (cancelled.contains(requestToken)) {
352
+ fetchConnections.remove(requestToken)?.disconnect()
353
+ return failure("CANCELLED", "File download was cancelled")
354
+ }
355
+ return try {
356
+ val status = connection.responseCode
357
+ if (status !in 200..299) return refusedDownload(connection, status, requestToken)
358
+ val name = options.optString("name").ifBlank { connection.url.path.substringAfterLast('/').ifBlank { "download" } }
359
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
360
+ connection.inputStream.use { input -> FileOutputStream(file).use { output ->
361
+ val buffer = ByteArray(8192)
362
+ while (true) {
363
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
364
+ val count = input.read(buffer)
365
+ if (count < 0) break
366
+ output.write(buffer, 0, count)
367
+ }
368
+ } }
369
+ success(descriptor(file, connection.contentType ?: "application/octet-stream"))
370
+ } catch (error: SocketTimeoutException) {
371
+ failure("TIMEOUT", error.message ?: "File download timed out")
372
+ } catch (error: Exception) {
373
+ if (cancelled.contains(requestToken)) failure("CANCELLED", "File download was cancelled")
374
+ else failure("NETWORK_ERROR", error.message ?: "File download failed")
375
+ } finally {
376
+ fetchConnections.remove(requestToken)?.disconnect()
377
+ connection.disconnect()
378
+ }
379
+ }
380
+
381
+ private fun refusedDownload(
382
+ connection: HttpURLConnection,
383
+ status: Int,
384
+ requestToken: String,
385
+ ): CraftNativeCapabilityResult {
386
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
387
+ val body = ByteArrayOutputStream()
388
+ connection.errorStream?.use { input ->
389
+ val buffer = ByteArray(8192)
390
+ while (body.size() < CRAFT_NATIVE_FILE_ERROR_BODY_BYTES) {
391
+ val count = input.read(buffer, 0, minOf(buffer.size, CRAFT_NATIVE_FILE_ERROR_BODY_BYTES - body.size()))
392
+ if (count < 0) break
393
+ body.write(buffer, 0, count)
394
+ }
395
+ }
396
+ return success(JSONObject().put("ok", false).put("status", status).put("body", body.toString(Charsets.UTF_8.name())))
397
+ }
398
+
399
+ private fun pickImage(
400
+ requestToken: String,
401
+ args: JSONArray,
402
+ completion: (CraftNativeCapabilityResult) -> Unit,
403
+ ) {
404
+ activity.runOnUiThread {
405
+ if (pendingFilePick != null) {
406
+ complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
407
+ return@runOnUiThread
408
+ }
409
+ val options = args.optJSONObject(0) ?: JSONObject()
410
+ val source = options.optString("source", "library")
411
+ if (source != "library" && source != "camera") {
412
+ complete(requestToken, failure("INVALID_ARGUMENT", "files.pickImage source is 'library' or 'camera'"), completion)
413
+ return@runOnUiThread
414
+ }
415
+ val maxPixels = options.optInt("maxPixels", CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS)
416
+ .coerceIn(64, CRAFT_NATIVE_FILE_LARGEST_MAX_PIXELS)
417
+ val quality = options.optDouble("quality", CRAFT_NATIVE_FILE_DEFAULT_QUALITY)
418
+ .coerceIn(0.1, 1.0).toFloat()
419
+ if (source == "camera" && !config.optBoolean("enableCamera")) {
420
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Camera is disabled"), completion)
421
+ return@runOnUiThread
422
+ }
423
+ pendingFilePick = PendingFilePick(requestToken, source, maxPixels, quality, completion)
424
+ val intent = if (source == "camera") Intent(MediaStore.ACTION_IMAGE_CAPTURE)
425
+ else Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
426
+ addCategory(Intent.CATEGORY_OPENABLE)
427
+ type = "image/*"
428
+ }
429
+ try {
430
+ activity.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST)
431
+ } catch (error: Exception) {
432
+ pendingFilePick = null
433
+ complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "Image picker could not be opened"), completion)
434
+ }
435
+ }
436
+ }
437
+
438
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
439
+ if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
440
+ val pending = pendingFilePick ?: return true
441
+ pendingFilePick = null
442
+ if (resultCode != android.app.Activity.RESULT_OK) {
443
+ complete(pending.requestToken, success(JSONObject.NULL), pending.completion)
444
+ return true
445
+ }
446
+ val descriptor = if (pending.source == "camera") {
447
+ val bitmap = data?.extras?.get("data") as? Bitmap
448
+ bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
449
+ } else {
450
+ val uri = data?.data
451
+ uri?.let { copyUri(it, pending.maxPixels, pending.quality) }
452
+ }
453
+ complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion)
454
+ return true
455
+ }
456
+
457
+ private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
458
+ val name = activity.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
459
+ if (it.moveToFirst()) it.getString(0) else null
460
+ } ?: "photo"
461
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
462
+ activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } ?: return null
463
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
464
+ var sample = 1
465
+ while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
466
+ val options = BitmapFactory.Options().apply { inSampleSize = sample }
467
+ val bitmap = activity.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
468
+ return writeImage(bitmap, maxPixels, quality, jpegName(name))
469
+ }
470
+
471
+ private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
472
+ val longest = max(bitmap.width, bitmap.height)
473
+ val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f
474
+ val output = if (scale < 1f) Bitmap.createScaledBitmap(
475
+ bitmap,
476
+ (bitmap.width * scale).roundToInt().coerceAtLeast(1),
477
+ (bitmap.height * scale).roundToInt().coerceAtLeast(1),
478
+ true,
479
+ ) else bitmap
480
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
481
+ val width = output.width
482
+ val height = output.height
483
+ val written = runCatching {
484
+ FileOutputStream(file).use { output.compress(Bitmap.CompressFormat.JPEG, (quality * 100).roundToInt(), it) }
485
+ }.getOrDefault(false)
486
+ if (output !== bitmap) output.recycle()
487
+ bitmap.recycle()
488
+ return if (written) descriptor(file, "image/jpeg").put("width", width).put("height", height) else null
489
+ }
490
+
491
+ private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult {
492
+ val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
493
+ return runCatching { file.writeBytes(bytes); success(descriptor(file, type)) }
494
+ .getOrElse { failure("WRITE_ERROR", it.message ?: "File could not be written") }
495
+ }
496
+
497
+ private fun descriptor(file: File, type: String): JSONObject = JSONObject()
498
+ .put("path", file.absolutePath)
499
+ .put("name", file.name)
500
+ .put("type", type)
501
+ .put("size", file.length())
502
+
503
+ private fun sweepFiles() {
504
+ val cutoff = System.currentTimeMillis() - 24L * 60L * 60L * 1000L
505
+ fileRoot.listFiles()?.forEach { file ->
506
+ if (file.lastModified() < cutoff) file.deleteRecursively()
507
+ }
508
+ }
509
+
510
+ private fun ownedFile(path: String): File? {
511
+ if (path.isBlank()) return null
512
+ val root = runCatching { fileRoot.canonicalFile }.getOrNull() ?: return null
513
+ val file = runCatching { File(path).canonicalFile }.getOrNull() ?: return null
514
+ return file.takeIf { it.path.startsWith(root.path + File.separator) }
515
+ }
516
+
517
+ private fun safeFileName(raw: String): String {
518
+ val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
519
+ return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
520
+ }
521
+
522
+ private fun jpegName(raw: String): String {
523
+ val safe = safeFileName(raw)
524
+ val base = safe.substringBeforeLast('.', safe).ifBlank { "photo" }
525
+ return "$base.jpg"
526
+ }
527
+
528
+ private fun quoted(value: String): String = value.replace("\\", "%5C").replace("\"", "%22").replace("\r", "%0D").replace("\n", "%0A")
529
+ private fun headerValue(value: String): String = value.replace("\r", "").replace("\n", "")
530
+ private fun validURL(raw: String): URL? = runCatching { URL(raw) }.getOrNull()?.takeIf { it.protocol.lowercase() in setOf("http", "https") && it.host.isNotBlank() }
531
+
227
532
  private fun complete(
228
533
  requestToken: String,
229
534
  result: CraftNativeCapabilityResult,