craft-native 0.0.490 → 0.0.492

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.
@@ -1,2 +1,9 @@
1
1
  /** Uses the Android builder's Kotlin version, including its transitive compiler dependencies. */
2
2
  export declare function runKotlinFixture(source: string, libraries?: readonly string[]): Promise<void>;
3
+ /**
4
+ * One member of a Kotlin class in a template, as shipped, so a fixture can run
5
+ * the real text rather than a copy of it: a function, or a property with its
6
+ * initialiser. Members sit at four spaces; the member is everything deeper,
7
+ * to its closing brace when it has a block body.
8
+ */
9
+ export declare function liftKotlinMember(template: string, name: string): string;
@@ -34,10 +34,12 @@ import org.json.JSONTokener
34
34
  import java.io.ByteArrayOutputStream
35
35
  import java.io.File
36
36
  import java.io.FileOutputStream
37
+ import java.io.IOException
37
38
  import java.net.HttpURLConnection
38
39
  import java.net.SocketTimeoutException
39
40
  import java.net.URL
40
41
  import java.net.URLConnection
42
+ import java.net.URLDecoder
41
43
  import java.util.UUID
42
44
  import java.util.concurrent.ConcurrentHashMap
43
45
  import java.util.concurrent.Executors
@@ -540,8 +542,7 @@ internal class CraftNativeCapabilities(
540
542
  val options = args.optJSONObject(2) ?: JSONObject()
541
543
  val bytes = when (options.optString("encoding", "utf8")) {
542
544
  "utf8" -> data.toByteArray(Charsets.UTF_8)
543
- "base64" -> runCatching { Base64.decode(data, Base64.DEFAULT) }
544
- .getOrElse { return failure("INVALID_ARGUMENT", "files.write base64 is invalid") }
545
+ "base64" -> decodeBase64(data) ?: return failure("INVALID_ARGUMENT", "files.write base64 is invalid")
545
546
  else -> return failure("INVALID_ARGUMENT", "files.write encoding is 'utf8' or 'base64'")
546
547
  }
547
548
  store(bytes, name, options.optString("type").ifBlank { URLConnection.guessContentTypeFromName(name) ?: "application/octet-stream" })
@@ -555,6 +556,30 @@ internal class CraftNativeCapabilities(
555
556
  }
556
557
  }
557
558
 
559
+ /**
560
+ * Base64 as `atob` reads it, which is what the browser host runs and what
561
+ * UIKit is held to: the alphabet, ASCII white space anywhere, padding
562
+ * optional but right when it is there, and nothing else. Android's own
563
+ * decoder skips any character it does not know, so the `-` and `_` of a
564
+ * URL-safe encoding would vanish and a wrong file be written with no
565
+ * error. It is handed only what has already been read as valid.
566
+ */
567
+ private fun decodeBase64(text: String): ByteArray? {
568
+ val data = StringBuilder(text.length)
569
+ var padding = 0
570
+ for (letter in text) {
571
+ when {
572
+ letter == ' ' || letter == '\t' || letter == '\n' || letter == '\u000C' || letter == '\r' -> Unit
573
+ letter == '=' -> padding += 1
574
+ padding > 0 -> return null
575
+ letter in 'A'..'Z' || letter in 'a'..'z' || letter in '0'..'9' || letter == '+' || letter == '/' -> data.append(letter)
576
+ else -> return null
577
+ }
578
+ }
579
+ if (padding > 2 || data.length % 4 == 1 || (padding > 0 && (data.length + padding) % 4 != 0)) return null
580
+ return runCatching { Base64.decode(data.toString(), Base64.DEFAULT) }.getOrNull()
581
+ }
582
+
558
583
  private fun download(requestToken: String, args: JSONArray): CraftNativeCapabilityResult {
559
584
  val address = args.optString(0).trim()
560
585
  val url = validURL(address) ?: return failure("INVALID_ARGUMENT", "files.download needs an absolute http or https URL")
@@ -585,18 +610,47 @@ internal class CraftNativeCapabilities(
585
610
  return try {
586
611
  val status = connection.responseCode
587
612
  if (status !in 200..299) return refusedDownload(connection, status, requestToken)
588
- val name = options.optString("name").ifBlank { connection.url.path.substringAfterLast('/').ifBlank { "download" } }
613
+ // The name the screen asked for, else the server's, else the
614
+ // address's: the order UIKit's download keeps a file under.
615
+ val name = options.optString("name").ifBlank { downloadName(connection.getHeaderField("Content-Disposition"), connection.url.path) }
589
616
  val file = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
590
- connection.inputStream.use { input -> FileOutputStream(file).use { output ->
591
- val buffer = ByteArray(8192)
592
- while (true) {
593
- if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
594
- val count = input.read(buffer)
595
- if (count < 0) break
596
- output.write(buffer, 0, count)
617
+ var kept = false
618
+ try {
619
+ val output = try { FileOutputStream(file) } catch (error: IOException) {
620
+ return failure("WRITE_ERROR", error.message ?: "File could not be written")
597
621
  }
598
- } }
599
- success(descriptor(file, connection.contentType ?: "application/octet-stream"))
622
+ var written = 0L
623
+ output.use { connection.inputStream.use { input ->
624
+ val buffer = ByteArray(8192)
625
+ while (true) {
626
+ if (cancelled.contains(requestToken)) return failure("CANCELLED", "File download was cancelled")
627
+ val count = input.read(buffer)
628
+ if (count < 0) break
629
+ // A full disk is not the network's failure.
630
+ try { output.write(buffer, 0, count) } catch (error: IOException) {
631
+ return failure("WRITE_ERROR", error.message ?: "File could not be written")
632
+ }
633
+ written += count
634
+ }
635
+ } }
636
+ // A body shorter than the server said it would be is not the
637
+ // file. Android's connection fails such a read itself, and
638
+ // other implementations simply stop, so the length is checked
639
+ // here and the answer is this function's own. (When the
640
+ // connection decompressed the body it reports no length.)
641
+ val declared = connection.getHeaderField("Content-Length")?.trim()?.toLongOrNull()
642
+ if (declared != null && written != declared) {
643
+ // A cancel can end the read early rather than fail it.
644
+ return if (cancelled.contains(requestToken)) failure("CANCELLED", "File download was cancelled")
645
+ else failure("NETWORK_ERROR", "File download ended after $written of $declared bytes")
646
+ }
647
+ kept = true
648
+ // `ok` is how a screen tells this from a refusal, which
649
+ // resolves `{ ok: false, status, body }` rather than rejecting.
650
+ success(descriptor(file, connection.contentType ?: "application/octet-stream").put("ok", true).put("status", status))
651
+ } finally {
652
+ if (!kept) removePartial(file)
653
+ }
600
654
  } catch (error: SocketTimeoutException) {
601
655
  failure("TIMEOUT", error.message ?: "File download timed out")
602
656
  } catch (error: Exception) {
@@ -608,6 +662,38 @@ internal class CraftNativeCapabilities(
608
662
  }
609
663
  }
610
664
 
665
+ /**
666
+ * A download that stopped part-way is not a file. Nothing was told its
667
+ * path, so nothing would remove it before the daily sweep.
668
+ */
669
+ private fun removePartial(file: File) {
670
+ runCatching { file.delete() }
671
+ // Its folder was made for it alone; delete refuses one that is not empty.
672
+ runCatching { file.parentFile?.takeIf { it != fileRoot }?.delete() }
673
+ }
674
+
675
+ /**
676
+ * The server's name for a download (`Content-Disposition`, the encoded
677
+ * `filename*` before the plain one), else the address's last segment as a
678
+ * person reads it rather than as it is percent-encoded.
679
+ */
680
+ private fun downloadName(disposition: String?, path: String): String {
681
+ val header = disposition.orEmpty()
682
+ Regex("filename\\*\\s*=\\s*UTF-8''([^;\\s]+)", RegexOption.IGNORE_CASE).find(header)?.let { match ->
683
+ percentDecoded(match.groupValues[1])?.takeIf { it.isNotBlank() }?.let { return it }
684
+ }
685
+ Regex("filename\\s*=\\s*(?:\"((?:[^\"\\\\]|\\\\.)*)\"|([^;]+))", RegexOption.IGNORE_CASE).find(header)?.let { match ->
686
+ val quoted = match.groups[1]?.value?.replace(Regex("\\\\(.)")) { it.groupValues[1] }
687
+ (quoted ?: match.groupValues[2]).trim().takeIf { it.isNotBlank() }?.let { return it }
688
+ }
689
+ val segment = path.substringAfterLast('/')
690
+ return (percentDecoded(segment) ?: segment).ifBlank { "download" }
691
+ }
692
+
693
+ /** Percent-decoded as UTF-8, or null when it is not validly encoded. A `+` is a plus, not a space. */
694
+ private fun percentDecoded(value: String): String? =
695
+ runCatching { URLDecoder.decode(value.replace("+", "%2B"), "UTF-8") }.getOrNull()
696
+
611
697
  private fun refusedDownload(
612
698
  connection: HttpURLConnection,
613
699
  status: Int,
@@ -997,9 +1083,24 @@ internal class CraftNativeCapabilities(
997
1083
  return true
998
1084
  }
999
1085
 
1086
+ /**
1087
+ * A file name with no path in it that the disk will take, as UIKit's
1088
+ * `safeName` makes one: no leading dots (so never `..`, the folder
1089
+ * itself), and cut to fit. 255 bytes is what a name may be on disk and
1090
+ * 120 characters is where UIKit cuts; the extension stays, since it is
1091
+ * how a share sheet and an upload know what the file is.
1092
+ */
1000
1093
  private fun safeFileName(raw: String): String {
1001
- val name = raw.substringAfterLast('/').substringAfterLast('\\').trim()
1002
- return name.ifBlank { "file" }.replace(Regex("[\\\\/:*?\"<>|\\r\\n]"), "_")
1094
+ val name = raw.substringAfterLast('/').substringAfterLast('\\').trim().trimStart('.')
1095
+ val safe = name.replace(Regex("[\\\\/:*?\"<>|\\r\\n\\u0000]"), "_").ifBlank { "file" }
1096
+ fun fits(value: String) = value.codePointCount(0, value.length) <= 120 && value.toByteArray(Charsets.UTF_8).size <= 255
1097
+ if (fits(safe)) return safe
1098
+ val dot = safe.lastIndexOf('.')
1099
+ val extension = if (dot > 0 && safe.length - dot <= 16) safe.substring(dot) else ""
1100
+ var stem = safe.dropLast(extension.length)
1101
+ stem = stem.substring(0, stem.offsetByCodePoints(0, minOf(120, stem.codePointCount(0, stem.length))))
1102
+ while (stem.isNotEmpty() && !fits(stem + extension)) stem = stem.substring(0, stem.offsetByCodePoints(stem.length, -1))
1103
+ return stem + extension
1003
1104
  }
1004
1105
 
1005
1106
  private fun jpegName(raw: String): String {
@@ -1093,10 +1194,18 @@ internal class CraftNativeCapabilities(
1093
1194
  }
1094
1195
  }
1095
1196
 
1096
- private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult {
1197
+ private fun secureStorage(method: String, args: JSONArray): CraftNativeCapabilityResult = synchronized(CraftNativeStorage.secureLock) {
1198
+ // A write that did not reach the disk is a failure, and nothing is told
1199
+ // it happened: the store's own memory is put back (a failed commit
1200
+ // has already changed it), no isolate hears of it, and the caller is
1201
+ // refused, as UIKit refuses a Keychain delete that failed. Answering
1202
+ // `false` and telling every isolate the token was gone made a failed
1203
+ // sign-out look like a sign-out until the next launch read the disk.
1097
1204
  if (method == "clear") {
1098
1205
  return try {
1099
- success(encryptedSecureStorage.edit().clear().commit().also { storeObserver?.invoke("secure", null, null) })
1206
+ if (!CraftNativeStorage.commit(encryptedSecureStorage) { it.clear() }) return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
1207
+ storeObserver?.invoke("secure", null, null)
1208
+ success(true)
1100
1209
  } catch (error: Exception) {
1101
1210
  failure("SECURE_STORAGE_ERROR", error.message ?: "Secure storage could not be cleared")
1102
1211
  }
@@ -1106,14 +1215,18 @@ internal class CraftNativeCapabilities(
1106
1215
  when (method) {
1107
1216
  "set" -> {
1108
1217
  if (args.length() != 2 || args.opt(1) !is String) return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
1109
- if (encryptedSecureStorage.edit().putString(key, args.getString(1)).commit()) {
1110
- storeObserver?.invoke("secure", key, args.getString(1))
1218
+ val value = args.getString(1)
1219
+ if (CraftNativeStorage.commit(encryptedSecureStorage) { it.putString(key, value) }) {
1220
+ storeObserver?.invoke("secure", key, value)
1111
1221
  success(true)
1112
1222
  }
1113
1223
  else failure("SECURE_STORAGE_ERROR", "Secure storage could not be written")
1114
1224
  }
1115
1225
  "get" -> success(encryptedSecureStorage.getString(key, null) ?: JSONObject.NULL)
1116
- "remove" -> success(encryptedSecureStorage.edit().remove(key).commit().also { storeObserver?.invoke("secure", key, null) })
1226
+ "remove" -> if (CraftNativeStorage.commit(encryptedSecureStorage) { it.remove(key) }) {
1227
+ storeObserver?.invoke("secure", key, null)
1228
+ success(true)
1229
+ } else failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
1117
1230
  else -> failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
1118
1231
  }
1119
1232
  } catch (error: Exception) {