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