craft-native 0.0.358 → 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.
- package/dist/android/src/index.js +1 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +184 -14
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +18 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +28 -1
- package/dist/android/templates/CraftNativeShell.kt.template +20 -5
- package/dist/android/templates/CraftNativeSounds.kt.template +260 -0
- package/dist/android/templates/MainActivityNative.kt.template +175 -3
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeFiles.swift +5 -3
- package/dist/ios/templates/CraftNativeScreen.swift +169 -18
- package/dist/ios/templates/CraftNativeServices.swift +58 -10
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
214
|
-
marker.isFlat =
|
|
215
|
-
marker.rotation = markerBearings[id] ?: 0f
|
|
216
|
-
marker.setAnchor(0.5f,
|
|
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(
|
|
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()
|
|
@@ -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) {
|
|
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
|
-
|
|
687
|
-
if (
|
|
688
|
-
|
|
689
|
-
|
|
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) : [];
|