craft-native 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.
@@ -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,