craft-native 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/dist/android/templates/CraftNative.kt.template +24 -1
- package/dist/android/templates/CraftNativeCapabilities.kt.template +309 -4
- package/dist/android/templates/MainActivityNative.kt.template +606 -48
- package/dist/api/mobile.d.ts +3 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +2 -0
- package/dist/ios/src/index.js +23 -10
- package/dist/ios/src/testing.d.ts +7 -1
- package/dist/ios/templates/CraftHealth.swift +11 -0
- package/dist/ios/templates/CraftHybrid.swift +19 -0
- package/dist/ios/templates/CraftNativeScreen.swift +411 -46
- package/dist/ios/templates/testing/DriverTests.swift.template +161 -145
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|