craft-native 0.0.355 → 0.0.359

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.
@@ -964,6 +964,7 @@ import com.google.android.gms.pay.PayClient` : "").replace("{{NATIVE_WALLET_FIEL
964
964
  } catch (error: Exception) {
965
965
  done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured"))
966
966
  }` : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))'));
967
+ write("CraftNativeSounds.kt.template", "CraftNativeSounds.kt");
967
968
  if (config.enablePushNotifications)
968
969
  write("CraftNativeMessagingService.kt.template", "CraftNativeMessagingService.kt");
969
970
  else
@@ -112,6 +112,8 @@ internal class CraftNativeCapabilities(
112
112
  val quality: Float,
113
113
  val completion: (CraftNativeCapabilityResult) -> Unit,
114
114
  val videoLimit: Long = 0,
115
+ val selectionLimit: Int = 1,
116
+ val fileLimit: Long = 0,
115
117
  )
116
118
 
117
119
  private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -218,6 +220,7 @@ internal class CraftNativeCapabilities(
218
220
  "Files" -> when (method) {
219
221
  "pickImage" -> pickImage(requestToken, args, completion)
220
222
  "pickVideo" -> pickVideo(requestToken, args, completion)
223
+ "pickFile" -> pickFile(requestToken, args, completion)
221
224
  "download", "write", "remove" -> networkExecutor.execute {
222
225
  complete(requestToken, files(requestToken, method, args), completion)
223
226
  }
@@ -512,9 +515,10 @@ internal class CraftNativeCapabilities(
512
515
  }
513
516
 
514
517
  /** Copy with a bound even when a cloud provider does not report its size. */
515
- private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult {
518
+ private fun copyVideo(requestToken: String, uri: Uri, limit: Long): CraftNativeCapabilityResult {
516
519
  var target: File? = null
517
520
  return try {
521
+ if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
518
522
  val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
519
523
  if (it.moveToFirst()) it.getString(0) else null
520
524
  } ?: "video.mp4"
@@ -531,6 +535,7 @@ internal class CraftNativeCapabilities(
531
535
  while (true) {
532
536
  val count = source.read(buffer)
533
537
  if (count < 0) break
538
+ if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
534
539
  size += count
535
540
  require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
536
541
  output.write(buffer, 0, count)
@@ -541,7 +546,9 @@ internal class CraftNativeCapabilities(
541
546
  success(descriptor(destination, type))
542
547
  } catch (error: Exception) {
543
548
  target?.parentFile?.deleteRecursively()
544
- failure("READ_ERROR", error.message ?: "The video could not be read")
549
+ val message = error.message ?: "The video could not be read"
550
+ if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
551
+ else failure("READ_ERROR", message)
545
552
  }
546
553
  }
547
554
 
@@ -585,6 +592,76 @@ internal class CraftNativeCapabilities(
585
592
  }
586
593
  }
587
594
 
595
+ private fun pickFile(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
596
+ mainHandler.post {
597
+ val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
598
+ if (pendingFilePick != null) {
599
+ complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
600
+ return@post
601
+ }
602
+ val options = args.optJSONObject(0) ?: JSONObject()
603
+ val types = documentTypes(options.optJSONArray("contentTypes"))
604
+ if (types == null) {
605
+ complete(requestToken, failure("INVALID_ARGUMENT", "files.pickFile contentTypes must be MIME types, UTIs, or extensions"), completion)
606
+ return@post
607
+ }
608
+ val selections = options.optInt("maxSelections", 1).coerceIn(1, 20)
609
+ val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
610
+ pendingFilePick = PendingFilePick(
611
+ requestToken,
612
+ "file",
613
+ 0,
614
+ 1f,
615
+ completion,
616
+ selectionLimit = selections,
617
+ fileLimit = limit,
618
+ )
619
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
620
+ addCategory(Intent.CATEGORY_OPENABLE)
621
+ type = types.first
622
+ if (types.second.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, types.second)
623
+ putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selections > 1)
624
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
625
+ }
626
+ try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
627
+ catch (error: Exception) {
628
+ pendingFilePick = null
629
+ complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "File picker could not be opened"), completion)
630
+ }
631
+ }
632
+ }
633
+
634
+ /** Android's picker accepts MIME filters; map the common UTI/extension forms used by the shared API. */
635
+ private fun documentTypes(values: JSONArray?): Pair<String, Array<String>>? {
636
+ if (values == null || values.length() == 0) return "*/*" to emptyArray()
637
+ val mime = mutableListOf<String>()
638
+ for (index in 0 until values.length()) {
639
+ val raw = values.optString(index).trim().lowercase()
640
+ if (raw.isBlank()) return null
641
+ val type = when (raw) {
642
+ "public.data", "public.item", "public.content" -> "*/*"
643
+ "public.image" -> "image/*"
644
+ "public.movie" -> "video/*"
645
+ "public.audio" -> "audio/*"
646
+ "public.text" -> "text/*"
647
+ else -> when {
648
+ raw.contains('/') -> raw
649
+ raw == "gpx" -> "application/gpx+xml"
650
+ raw == "tcx" -> "application/vnd.garmin.tcx+xml"
651
+ raw == "fit" -> "application/octet-stream"
652
+ raw == "json" -> "application/json"
653
+ raw == "csv" -> "text/csv"
654
+ raw == "txt" -> "text/plain"
655
+ raw == "pdf" -> "application/pdf"
656
+ else -> return null
657
+ }
658
+ }
659
+ mime += type
660
+ }
661
+ val distinct = mime.distinct()
662
+ return if (distinct.size == 1) distinct.first() to emptyArray() else "*/*" to distinct.toTypedArray()
663
+ }
664
+
588
665
  fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
589
666
  if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
590
667
  val pending = pendingFilePick ?: return true
@@ -595,24 +672,45 @@ internal class CraftNativeCapabilities(
595
672
  }
596
673
  if (pending.source == "video") {
597
674
  networkExecutor.execute {
598
- val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
675
+ val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
599
676
  ?: failure("PICKER_FAILED", "The selected video could not be read")
600
677
  complete(pending.requestToken, result, pending.completion)
601
678
  }
602
679
  return true
603
680
  }
604
- val descriptor = if (pending.source == "camera") {
605
- val bitmap = data?.extras?.get("data") as? Bitmap
606
- bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
607
- } else {
608
- val uri = data?.data
609
- uri?.let { copyUri(it, pending.maxPixels, pending.quality) }
681
+ if (pending.source == "file") {
682
+ networkExecutor.execute {
683
+ val uris = buildList {
684
+ data?.clipData?.let { clips ->
685
+ for (index in 0 until clips.itemCount) add(clips.getItemAt(index).uri)
686
+ }
687
+ data?.data?.let { add(it) }
688
+ }.distinct().take(pending.selectionLimit)
689
+ val result = copyDocuments(pending.requestToken, uris, pending.fileLimit)
690
+ complete(pending.requestToken, result, pending.completion)
691
+ }
692
+ return true
693
+ }
694
+ networkExecutor.execute {
695
+ val descriptor = if (pending.source == "camera") {
696
+ val bitmap = data?.extras?.get("data") as? Bitmap
697
+ bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg", pending.requestToken) }
698
+ } else {
699
+ val uri = data?.data
700
+ uri?.let { copyUri(pending.requestToken, it, pending.maxPixels, pending.quality) }
701
+ }
702
+ complete(
703
+ pending.requestToken,
704
+ descriptor?.let(::success) ?: if (cancelled.contains(pending.requestToken)) failure("CANCELLED", "Image selection was cancelled")
705
+ else failure("PICKER_FAILED", "The selected image could not be read"),
706
+ pending.completion,
707
+ )
610
708
  }
611
- complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion)
612
709
  return true
613
710
  }
614
711
 
615
- private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
712
+ private fun copyUri(requestToken: String, uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
713
+ if (cancelled.contains(requestToken)) return null
616
714
  val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
617
715
  if (it.moveToFirst()) it.getString(0) else null
618
716
  } ?: "photo"
@@ -623,10 +721,74 @@ internal class CraftNativeCapabilities(
623
721
  while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
624
722
  val options = BitmapFactory.Options().apply { inSampleSize = sample }
625
723
  val bitmap = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
626
- return writeImage(bitmap, maxPixels, quality, jpegName(name))
724
+ return writeImage(bitmap, maxPixels, quality, jpegName(name), requestToken)
627
725
  }
628
726
 
629
- private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
727
+ private fun copyDocuments(requestToken: String, uris: List<Uri>, limit: Long): CraftNativeCapabilityResult {
728
+ if (uris.isEmpty()) return failure("PICKER_FAILED", "No document was selected")
729
+ val copied = JSONArray()
730
+ return try {
731
+ uris.forEach { copied.put(copyDocument(requestToken, it, limit)) }
732
+ if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
733
+ success(copied)
734
+ } catch (error: Exception) {
735
+ for (index in 0 until copied.length()) {
736
+ copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.parentFile?.deleteRecursively() }
737
+ }
738
+ val message = error.message ?: "The selected document could not be read"
739
+ if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
740
+ else if (message.startsWith("FILE_TOO_LARGE:")) failure("FILE_TOO_LARGE", message.removePrefix("FILE_TOO_LARGE:").trim())
741
+ else failure("READ_ERROR", message)
742
+ }
743
+ }
744
+
745
+ private fun copyDocument(requestToken: String, uri: Uri, limit: Long): JSONObject {
746
+ var target: File? = null
747
+ return try {
748
+ val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
749
+ if (it.moveToFirst()) it.getString(0) else null
750
+ } ?: "file"
751
+ val type = context.contentResolver.getType(uri) ?: documentMimeType(name)
752
+ val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
753
+ target = destination
754
+ val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The selected document could not be read")
755
+ var size = 0L
756
+ input.use { source ->
757
+ destination.outputStream().use { output ->
758
+ val buffer = ByteArray(64 * 1024)
759
+ while (true) {
760
+ val count = source.read(buffer)
761
+ if (count < 0) break
762
+ if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
763
+ size += count
764
+ require(size <= limit) { "FILE_TOO_LARGE: Choose a file under ${limit / 1024 / 1024}MB." }
765
+ output.write(buffer, 0, count)
766
+ }
767
+ }
768
+ }
769
+ require(size > 0) { "Choose a nonempty file." }
770
+ descriptor(destination, type)
771
+ } catch (error: Exception) {
772
+ target?.parentFile?.deleteRecursively()
773
+ throw error
774
+ }
775
+ }
776
+
777
+ private fun documentMimeType(name: String): String = when (name.substringAfterLast('.', "").lowercase()) {
778
+ "gpx" -> "application/gpx+xml"
779
+ "tcx" -> "application/vnd.garmin.tcx+xml"
780
+ "json" -> "application/json"
781
+ "csv" -> "text/csv"
782
+ "txt" -> "text/plain"
783
+ "pdf" -> "application/pdf"
784
+ else -> "application/octet-stream"
785
+ }
786
+
787
+ private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String, requestToken: String? = null): JSONObject? {
788
+ if (requestToken != null && cancelled.contains(requestToken)) {
789
+ bitmap.recycle()
790
+ return null
791
+ }
630
792
  val longest = max(bitmap.width, bitmap.height)
631
793
  val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f
632
794
  val output = if (scale < 1f) Bitmap.createScaledBitmap(
@@ -643,7 +805,15 @@ internal class CraftNativeCapabilities(
643
805
  }.getOrDefault(false)
644
806
  if (output !== bitmap) output.recycle()
645
807
  bitmap.recycle()
646
- return if (written) descriptor(file, "image/jpeg").put("width", width).put("height", height) else null
808
+ if (!written) {
809
+ file.parentFile?.deleteRecursively()
810
+ return null
811
+ }
812
+ if (requestToken != null && cancelled.contains(requestToken)) {
813
+ file.parentFile?.deleteRecursively()
814
+ return null
815
+ }
816
+ return descriptor(file, "image/jpeg").put("width", width).put("height", height)
647
817
  }
648
818
 
649
819
  private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult {
@@ -26,6 +26,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
26
26
  private var userChangingCamera = false
27
27
  private var markerBearings = mutableMapOf<String, Float>()
28
28
  private var markerPinColors = mutableMapOf<String, Int>()
29
+ private val markerPhotos = CraftMarkerPhotos(context) { if (!destroyed) applyPending() }
29
30
  private val pinIcons = mutableMapOf<Int, com.google.android.gms.maps.model.BitmapDescriptor>()
30
31
  private var directionIcon: com.google.android.gms.maps.model.BitmapDescriptor? = null
31
32
  private var lifecycleStarted = false
@@ -122,6 +123,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
122
123
  val nextMarkers = mutableMapOf<String, Pair<LatLng, String?>>()
123
124
  val nextBearings = mutableMapOf<String, Float>()
124
125
  val nextPinColors = mutableMapOf<String, Int>()
126
+ val nextPhotos = mutableMapOf<String, CraftMarkerPhoto>()
125
127
  val nextHandlers = mutableMapOf<String, String>()
126
128
  val nextPolylines = mutableMapOf<String, Triple<List<LatLng>, Int, Float>>()
127
129
  val nextPolygons = mutableMapOf<String, PolygonValue>()
@@ -131,6 +133,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
131
133
  val id = craftNativeOptionalString(overlay.opt("id")) ?: continue
132
134
  val point = coordinates(overlay.optJSONArray("geometry")).firstOrNull() ?: continue
133
135
  nextMarkers[id] = point to (overlay.opt("title") as? String)
136
+ CraftMarkerPhoto.from(overlay)?.let { nextPhotos[id] = it }
134
137
  (overlay.opt("pinColor") as? String)?.let { nextPinColors[id] = color(it) }
135
138
  (overlay.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
136
139
  }
@@ -161,6 +164,8 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
161
164
  if (type == "mapmarker" || type == "marker") {
162
165
  points.firstOrNull()?.let { point ->
163
166
  nextMarkers[id] = point to (props.opt("title") as? String)
167
+ nextPhotos.remove(id)
168
+ CraftMarkerPhoto.from(props)?.let { nextPhotos[id] = it }
164
169
  (props.opt("pinColor") as? String)?.let { value -> nextPinColors[id] = color(value) }
165
170
  (props.opt("bearing") as? Number)?.toDouble()?.takeIf { it.isFinite() }?.let { nextBearings[id] = (it % 360).toFloat() }
166
171
  listOf(events.opt("onPress"), events.opt("onMarkerPress"))
@@ -203,19 +208,26 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
203
208
  }
204
209
  markerBearings = nextBearings
205
210
  markerPinColors = nextPinColors
211
+ markerPhotos.reconcile(nextPhotos)
206
212
  pinIcons.keys.filter { it !in markerPinColors.values }.forEach { pinIcons.remove(it) }
207
213
  markers.keys.filter { nextMarkers[it] == null }.forEach { markers.remove(it)?.remove() }
208
214
  for ((id, value) in nextMarkers) {
215
+ val photo = markerPhotos.bitmap(id)
216
+ val direction = photo == null && markerBearings.containsKey(id)
217
+ val icon = photo?.let(BitmapDescriptorFactory::fromBitmap)
218
+ ?: if (direction) directionMarkerIcon() else pinMarkerIcon(id)
219
+ val anchor = if (photo != null || direction || markerPinColors.containsKey(id)) 0.5f else 1f
209
220
  val marker = markers[id]
210
221
  if (marker != null) {
211
222
  marker.position = value.first
212
223
  marker.title = value.second
213
- marker.setIcon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id))
214
- marker.isFlat = markerBearings.containsKey(id)
215
- marker.rotation = markerBearings[id] ?: 0f
216
- marker.setAnchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f)
224
+ marker.setIcon(icon)
225
+ marker.isFlat = direction
226
+ marker.rotation = if (direction) markerBearings[id] ?: 0f else 0f
227
+ marker.setAnchor(0.5f, anchor)
217
228
  } else {
218
- map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(if (markerBearings.containsKey(id)) directionMarkerIcon() else pinMarkerIcon(id)).flat(markerBearings.containsKey(id)).rotation(markerBearings[id] ?: 0f).anchor(0.5f, if (markerBearings.containsKey(id) || markerPinColors.containsKey(id)) 0.5f else 1f))?.let { markers[id] = it }
229
+ map.addMarker(MarkerOptions().position(value.first).title(value.second).icon(icon).flat(direction)
230
+ .rotation(if (direction) markerBearings[id] ?: 0f else 0f).anchor(0.5f, anchor))?.let { markers[id] = it }
219
231
  }
220
232
  }
221
233
  markerHandlers.clear()
@@ -445,6 +457,7 @@ private class CraftNativeMapView(context: Context) : FrameLayout(context), OnMap
445
457
  fun destroy() {
446
458
  if (destroyed) return
447
459
  destroyed = true
460
+ markerPhotos.destroy()
448
461
  if (lifecycleStarted) {
449
462
  mapView.onPause()
450
463
  mapView.onStop()
@@ -26,6 +26,8 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
26
26
  private var userChangingCamera = false
27
27
  private var geometryPoints = emptyList<LatLng>()
28
28
  private val markerHandlers = mutableMapOf<String, String>()
29
+ private val markerPhotos = CraftMarkerPhotos(context) { if (!destroyed) readyStyle?.let(::updateOverlays) }
30
+ private val installedPhotos = mutableMapOf<String, Bitmap>()
29
31
  private var onMarkerTap: ((String) -> Unit)? = null
30
32
  var onMapPress: ((LatLng) -> Unit)? = null
31
33
  var onRegionChangeComplete: ((JSONObject) -> Unit)? = null
@@ -83,7 +85,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
83
85
  nativeMap.addOnMapClickListener { point ->
84
86
  val screen = nativeMap.projection.toScreenLocation(point)
85
87
  val radius = 18 * resources.displayMetrics.density
86
- val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions").firstOrNull()
88
+ val hit = nativeMap.queryRenderedFeatures(android.graphics.RectF(screen.x - radius, screen.y - radius, screen.x + radius, screen.y + radius), "craft-map-markers", "craft-map-directions", "craft-map-photos").firstOrNull()
87
89
  val id = hit?.takeIf { it.hasProperty("craftMarkerId") }?.getStringProperty("craftMarkerId")
88
90
  if (id != null) onMarkerTap?.invoke(id) else onMapPress?.invoke(point)
89
91
  true
@@ -247,6 +249,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
247
249
  .put("type", "raster").put("tiles", JSONArray().put(url)).put("tileSize", 256).put("maxzoom", 19).put("attribution", credit)
248
250
 
249
251
  private fun installOverlays(style: Style) {
252
+ installedPhotos.clear()
250
253
  style.addSource(GeoJsonSource("craft-map-overlays"))
251
254
  style.addImage("craft-map-direction", directionBitmap())
252
255
  style.addLayer(LineLayer("craft-map-routes", "craft-map-overlays")
@@ -264,11 +267,29 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
264
267
  style.addLayer(SymbolLayer("craft-map-directions", "craft-map-overlays")
265
268
  .withFilter(Expression.eq(Expression.get("kind"), "direction"))
266
269
  .withProperties(iconImage("craft-map-direction"), iconRotate(Expression.get("bearing")), iconRotationAlignment("map"), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center")))
270
+ style.addLayer(SymbolLayer("craft-map-photos", "craft-map-overlays")
271
+ .withFilter(Expression.eq(Expression.get("kind"), "photo"))
272
+ .withProperties(iconImage(Expression.get("photoIcon")), iconAllowOverlap(true), iconIgnorePlacement(true), iconAnchor("center")))
267
273
  }
268
274
 
269
275
  private fun updateOverlays(style: Style) {
270
276
  val features = JSONArray()
271
277
  val pointsForFit = mutableListOf<LatLng>()
278
+ val wantedPhotos = mutableMapOf<String, CraftMarkerPhoto>()
279
+ val usedImages = mutableSetOf<String>()
280
+ fun photo(id: String, props: JSONObject, properties: JSONObject) {
281
+ wantedPhotos.remove(id)
282
+ val spec = CraftMarkerPhoto.from(props) ?: return
283
+ wantedPhotos[id] = spec
284
+ val bitmap = markerPhotos.bitmap(id, spec) ?: return
285
+ val key = "craft-map-photo-${CraftNativeMedia.digest(id)}"
286
+ usedImages.add(key)
287
+ if (installedPhotos[key] !== bitmap) {
288
+ style.addImage(key, bitmap)
289
+ installedPhotos[key] = bitmap
290
+ }
291
+ properties.put("kind", "photo").put("photoIcon", key)
292
+ }
272
293
  markerHandlers.clear()
273
294
  val dataMarkers = pendingProps.optJSONArray("markers")
274
295
  for (index in 0 until (dataMarkers?.length() ?: 0)) {
@@ -282,6 +303,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
282
303
  .put("craftMarkerId", id)
283
304
  .put("bearing", (bearing ?: 0.0) % 360)
284
305
  .put("color", cssColor(overlay.opt("pinColor"), Color.rgb(36, 114, 75)))
306
+ photo(id, overlay, properties)
285
307
  val geometry = JSONObject().put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
286
308
  features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
287
309
  pointsForFit.add(point)
@@ -320,6 +342,7 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
320
342
  val bearing = finiteNumber(props.opt("bearing"))
321
343
  properties.put("kind", if (bearing == null) "marker" else "direction").put("craftMarkerId", id)
322
344
  properties.put("bearing", (bearing ?: 0.0) % 360).put("color", cssColor(props.opt("pinColor"), Color.rgb(36, 114, 75)))
345
+ photo(id, props, properties)
323
346
  geometry.put("type", "Point").put("coordinates", JSONArray().put(point.longitude).put(point.latitude))
324
347
  listOf(events.opt("onPress"), events.opt("onMarkerPress")).firstNotNullOfOrNull(::craftNativeOptionalString)?.let { markerHandlers[id] = it }
325
348
  pointsForFit.add(point)
@@ -348,7 +371,9 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
348
371
  features.put(JSONObject().put("type", "Feature").put("properties", properties).put("geometry", geometry))
349
372
  }
350
373
  geometryPoints = pointsForFit
374
+ markerPhotos.reconcile(wantedPhotos)
351
375
  style.getSourceAs<GeoJsonSource>("craft-map-overlays")?.setGeoJson(JSONObject().put("type", "FeatureCollection").put("features", features).toString())
376
+ installedPhotos.keys.filter { it !in usedImages }.forEach { style.removeImage(it); installedPhotos.remove(it) }
352
377
  }
353
378
 
354
379
  private fun cssColor(value: Any?, fallback: Int): String {
@@ -521,6 +546,8 @@ internal class CraftNativeMapView(context: Context) : FrameLayout(context), OnMa
521
546
  fun destroy() {
522
547
  if (destroyed) return
523
548
  destroyed = true
549
+ markerPhotos.destroy()
550
+ installedPhotos.clear()
524
551
  styleVersion++
525
552
  onMarkerTap = null; onMapPress = null; onRegionChangeComplete = null
526
553
  markerHandlers.clear(); geometryPoints = emptyList()
@@ -162,6 +162,9 @@ class CraftNativeMutationDocument {
162
162
  )
163
163
  }
164
164
 
165
+ /** The committed root may have any protocol id supplied by the compiler. */
166
+ fun document(): JSONObject? = rootId?.let { materialize(it, nodes) }
167
+
165
168
  fun node(id: String): JSONObject? = materialize(id, nodes)
166
169
 
167
170
  /** Reserve surviving protocol nodes before reusing an authored keyed slot. */
@@ -373,6 +373,10 @@ internal class CraftNativeStores private constructor(
373
373
 
374
374
  /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */
375
375
  fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) {
376
+ if (!isSnapshotName(name)) {
377
+ main.post { done(false) }
378
+ return
379
+ }
376
380
  writer.execute {
377
381
  val file = snapshotFile(name)
378
382
  val previous = snapshotCache[name]
@@ -422,6 +426,8 @@ internal class CraftNativeStores private constructor(
422
426
  CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
423
427
  }
424
428
 
429
+ private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
430
+
425
431
  private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
426
432
 
427
433
  private fun snapshotName(file: File): String? =
@@ -659,6 +665,9 @@ internal object CraftNativePrelude {
659
665
  if (json === null || json === undefined) return null;
660
666
  try { return JSON.parse(json); } catch (error) { return null; }
661
667
  }
668
+ function validSnapshotName(name) {
669
+ return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name);
670
+ }
662
671
  var craft = globalThis.craft = globalThis.craft || {};
663
672
  function keep(name, extras) {
664
673
  var current = {};
@@ -681,12 +690,18 @@ internal object CraftNativePrelude {
681
690
  }
682
691
  });
683
692
  keep('snapshots', {
684
- get: function(name) { return parsed(sync.snapshots[String(name)]); },
693
+ get: function(name) {
694
+ name = String(name);
695
+ return validSnapshotName(name) ? parsed(sync.snapshots[name]) : null;
696
+ },
685
697
  set: function(name, value) {
686
- var json = value === null || value === undefined ? null : JSON.stringify(value);
687
- if (json === null) delete sync.snapshots[String(name)];
688
- else sync.snapshots[String(name)] = json;
689
- return wait('snapshots.set', { store: 'snapshots', key: String(name), value: json });
698
+ name = String(name);
699
+ if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "-"'));
700
+ var json = value === null ? null : JSON.stringify(value);
701
+ if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON'));
702
+ if (json === null) delete sync.snapshots[name];
703
+ else sync.snapshots[name] = json;
704
+ return wait('snapshots.set', { store: 'snapshots', key: name, value: json });
690
705
  },
691
706
  clear: function(keepNames) {
692
707
  var names = Array.isArray(keepNames) ? keepNames.map(String) : [];