craft-native 0.0.358 → 0.0.360
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/CraftApp.swift +3 -2
- package/dist/ios/templates/CraftBluetooth.swift +16 -4
- package/dist/ios/templates/CraftHybrid.swift +224 -15
- package/dist/ios/templates/CraftNativeActions.swift +50 -0
- package/dist/ios/templates/CraftNativeFiles.swift +5 -3
- package/dist/ios/templates/CraftNativeScreen.swift +203 -29
- package/dist/ios/templates/CraftNativeServices.swift +58 -10
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioAttributes
|
|
5
|
+
import android.media.AudioFocusRequest
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.media.MediaPlayer
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.io.File
|
|
13
|
+
import kotlin.math.max
|
|
14
|
+
import kotlin.math.min
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Screen-owned `craft.audio` playback for native Android routes.
|
|
18
|
+
*
|
|
19
|
+
* Each id owns one MediaPlayer, so effects can overlap and a replacement
|
|
20
|
+
* invalidates the previous load. The player is released on every terminal
|
|
21
|
+
* path, including cancellation, decode failure and screen teardown.
|
|
22
|
+
*/
|
|
23
|
+
internal class CraftNativeSounds(private val context: Context) {
|
|
24
|
+
private data class Sound(
|
|
25
|
+
val player: MediaPlayer,
|
|
26
|
+
var volume: Float,
|
|
27
|
+
var paused: Boolean = false,
|
|
28
|
+
var interrupted: Boolean = false,
|
|
29
|
+
var prepared: Boolean = false,
|
|
30
|
+
var fade: Runnable? = null,
|
|
31
|
+
var pending: ((CraftNativeCapabilityResult) -> Unit)? = null,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
private val main = Handler(Looper.getMainLooper())
|
|
35
|
+
private val sounds = mutableMapOf<String, Sound>()
|
|
36
|
+
private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
|
37
|
+
private val focusRequest by lazy {
|
|
38
|
+
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
39
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
40
|
+
.setUsage(AudioAttributes.USAGE_GAME)
|
|
41
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
42
|
+
.build())
|
|
43
|
+
.setAcceptsDelayedFocusGain(false)
|
|
44
|
+
.setOnAudioFocusChangeListener({ change -> onAudioFocusChange(change) }, main)
|
|
45
|
+
.build()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `{ id, event: "ended" | "error", message? }` for the screen bridge. */
|
|
49
|
+
var emit: ((JSONObject) -> Unit)? = null
|
|
50
|
+
|
|
51
|
+
fun play(id: String, address: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
52
|
+
if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
|
|
53
|
+
val uri = source(address)
|
|
54
|
+
?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
|
|
55
|
+
stop(id, 0.0)
|
|
56
|
+
|
|
57
|
+
val requestedVolume = options.optDouble("volume", 1.0)
|
|
58
|
+
val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
|
|
59
|
+
?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
|
|
60
|
+
val fadeIn = options.optDouble("fadeIn", 0.0)
|
|
61
|
+
if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
|
|
62
|
+
val player = MediaPlayer()
|
|
63
|
+
val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
|
|
64
|
+
sounds[id] = sound
|
|
65
|
+
fun settle(result: CraftNativeCapabilityResult) {
|
|
66
|
+
val answer = sound.pending ?: return
|
|
67
|
+
sound.pending = null
|
|
68
|
+
answer(result)
|
|
69
|
+
}
|
|
70
|
+
fun failed(message: String) {
|
|
71
|
+
if (sounds[id] !== sound) return
|
|
72
|
+
settle(failure("AUDIO_ERROR", message))
|
|
73
|
+
remove(id)
|
|
74
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
|
78
|
+
settle(failure("AUDIO_FOCUS_DENIED", "Audio focus was denied by the operating system"))
|
|
79
|
+
remove(id)
|
|
80
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was denied by the operating system"))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
runCatching {
|
|
85
|
+
player.setAudioAttributes(AudioAttributes.Builder()
|
|
86
|
+
.setUsage(AudioAttributes.USAGE_GAME)
|
|
87
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
88
|
+
.build())
|
|
89
|
+
player.isLooping = options.optBoolean("loop", false)
|
|
90
|
+
player.setVolume(if (fadeIn > 0) 0f else volume, if (fadeIn > 0) 0f else volume)
|
|
91
|
+
player.setOnPreparedListener {
|
|
92
|
+
if (sounds[id] !== sound) return@setOnPreparedListener
|
|
93
|
+
sound.prepared = true
|
|
94
|
+
if (!sound.paused && !sound.interrupted) player.start()
|
|
95
|
+
settle(CraftNativeCapabilityResult(data = true))
|
|
96
|
+
if (fadeIn > 0) {
|
|
97
|
+
if (!sound.paused) ramp(sound, volume, fadeIn / 1000.0)
|
|
98
|
+
else { sound.volume = volume; player.setVolume(volume, volume) }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
player.setOnCompletionListener {
|
|
102
|
+
if (sounds[id] !== sound) return@setOnCompletionListener
|
|
103
|
+
remove(id)
|
|
104
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "ended"))
|
|
105
|
+
}
|
|
106
|
+
player.setOnErrorListener { _, what, extra ->
|
|
107
|
+
failed("The sound could not be played ($what, $extra)")
|
|
108
|
+
true
|
|
109
|
+
}
|
|
110
|
+
player.setDataSource(context, uri)
|
|
111
|
+
player.prepareAsync()
|
|
112
|
+
}.onFailure { error ->
|
|
113
|
+
if (sounds[id] === sound) {
|
|
114
|
+
settle(failure("AUDIO_ERROR", error.message ?: "The sound could not be loaded"))
|
|
115
|
+
remove(id)
|
|
116
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", error.message ?: "The sound could not be loaded"))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cancellation belongs to this playback, not a later reuse of its id. */
|
|
122
|
+
fun cancellation(id: String): () -> Unit {
|
|
123
|
+
val sound = sounds[id]
|
|
124
|
+
return { if (sound != null && sounds[id] === sound) remove(id) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fun pause(id: String): Boolean {
|
|
128
|
+
val sound = sounds[id] ?: return false
|
|
129
|
+
sound.fade?.let(main::removeCallbacks)
|
|
130
|
+
sound.fade = null
|
|
131
|
+
runCatching { if (sound.player.isPlaying) sound.player.pause() }
|
|
132
|
+
sound.paused = true
|
|
133
|
+
sound.interrupted = false
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun resume(id: String): Boolean {
|
|
138
|
+
val sound = sounds[id] ?: return false
|
|
139
|
+
if (sound.prepared) runCatching { sound.player.start() }.onFailure { return false }
|
|
140
|
+
sound.paused = false
|
|
141
|
+
sound.interrupted = false
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fun setVolume(id: String, volume: Double, fade: Double): Boolean {
|
|
146
|
+
val sound = sounds[id] ?: return false
|
|
147
|
+
if (!volume.isFinite() || !fade.isFinite() || fade < 0) return false
|
|
148
|
+
val target = volume.coerceIn(0.0, 1.0).toFloat()
|
|
149
|
+
sound.fade?.let(main::removeCallbacks)
|
|
150
|
+
sound.fade = null
|
|
151
|
+
if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
|
|
152
|
+
else {
|
|
153
|
+
sound.volume = target
|
|
154
|
+
sound.player.setVolume(target, target)
|
|
155
|
+
}
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fun stop(id: String, fadeOut: Double): Boolean {
|
|
160
|
+
val sound = sounds[id] ?: return false
|
|
161
|
+
if (!fadeOut.isFinite() || fadeOut < 0) return false
|
|
162
|
+
if (fadeOut > 0 && !sound.paused) {
|
|
163
|
+
ramp(sound, 0f, fadeOut / 1000.0) { if (sounds[id] === sound) remove(id) }
|
|
164
|
+
} else remove(id)
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fun stopAll(fadeOut: Double): Boolean {
|
|
169
|
+
if (!fadeOut.isFinite() || fadeOut < 0) return false
|
|
170
|
+
sounds.keys.toList().forEach { stop(it, fadeOut) }
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fun close() {
|
|
175
|
+
sounds.keys.toList().forEach(::remove)
|
|
176
|
+
emit = null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun source(address: String): Uri? {
|
|
180
|
+
val text = address.trim()
|
|
181
|
+
if (text.isBlank()) return null
|
|
182
|
+
CraftNativeMedia.local(context, text)?.let { file: File -> return Uri.fromFile(file) }
|
|
183
|
+
val uri = runCatching { Uri.parse(text) }.getOrNull() ?: return null
|
|
184
|
+
return uri.takeIf { it.scheme?.lowercase() in setOf("http", "https", "file", "content") && !it.toString().contains(" ") }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun remove(id: String) {
|
|
188
|
+
val sound = sounds.remove(id) ?: return
|
|
189
|
+
sound.fade?.let(main::removeCallbacks)
|
|
190
|
+
sound.fade = null
|
|
191
|
+
runCatching {
|
|
192
|
+
sound.player.setOnPreparedListener(null)
|
|
193
|
+
sound.player.setOnCompletionListener(null)
|
|
194
|
+
sound.player.setOnErrorListener(null)
|
|
195
|
+
sound.player.stop()
|
|
196
|
+
}
|
|
197
|
+
sound.player.release()
|
|
198
|
+
if (sounds.isEmpty()) audioManager.abandonAudioFocusRequest(focusRequest)
|
|
199
|
+
val answer = sound.pending
|
|
200
|
+
sound.pending = null
|
|
201
|
+
answer?.invoke(failure("CANCELLED", "Audio playback was stopped or replaced"))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun onAudioFocusChange(change: Int) {
|
|
205
|
+
when (change) {
|
|
206
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
207
|
+
sounds.keys.toList().forEach { id ->
|
|
208
|
+
if (sounds[id] != null) {
|
|
209
|
+
remove(id)
|
|
210
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was lost"))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> sounds.values.toList().forEach { sound ->
|
|
215
|
+
if (!sound.paused) {
|
|
216
|
+
sound.interrupted = true
|
|
217
|
+
if (sound.prepared) runCatching { sound.player.pause() }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> sounds.values.toList().forEach { sound ->
|
|
221
|
+
runCatching { sound.player.setVolume(sound.volume * 0.2f, sound.volume * 0.2f) }
|
|
222
|
+
}
|
|
223
|
+
AudioManager.AUDIOFOCUS_GAIN -> sounds.values.toList().forEach { sound ->
|
|
224
|
+
if (sound.interrupted && !sound.paused) {
|
|
225
|
+
sound.interrupted = false
|
|
226
|
+
if (sound.prepared) runCatching { sound.player.start() }
|
|
227
|
+
} else {
|
|
228
|
+
runCatching { sound.player.setVolume(sound.volume, sound.volume) }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun ramp(sound: Sound, target: Float, seconds: Double, done: (() -> Unit)? = null) {
|
|
235
|
+
sound.fade?.let(main::removeCallbacks)
|
|
236
|
+
val start = sound.volume
|
|
237
|
+
val began = android.os.SystemClock.uptimeMillis()
|
|
238
|
+
val duration = max(1L, (seconds * 1000).toLong())
|
|
239
|
+
val tick = object : Runnable {
|
|
240
|
+
override fun run() {
|
|
241
|
+
if (!sounds.containsValue(sound)) return
|
|
242
|
+
val progress = min(1.0, (android.os.SystemClock.uptimeMillis() - began).toDouble() / duration).toFloat()
|
|
243
|
+
val value = start + (target - start) * progress
|
|
244
|
+
sound.player.setVolume(value, value)
|
|
245
|
+
sound.volume = value
|
|
246
|
+
if (progress >= 1f) {
|
|
247
|
+
sound.fade = null
|
|
248
|
+
done?.invoke()
|
|
249
|
+
} else {
|
|
250
|
+
sound.fade = this
|
|
251
|
+
main.postDelayed(this, 33L)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
sound.fade = tick
|
|
256
|
+
main.post(tick)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
260
|
+
}
|
|
@@ -740,6 +740,115 @@ private class CraftNativeSvgView(context: Context) : View(context) {
|
|
|
740
740
|
private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
/** One photo contract and loader shared by the Google and MapLibre hosts. */
|
|
744
|
+
internal data class CraftMarkerPhoto(val address: String, val size: Float, val circle: Boolean) {
|
|
745
|
+
companion object {
|
|
746
|
+
fun from(props: JSONObject): CraftMarkerPhoto? {
|
|
747
|
+
val address = props.opt("image") as? String ?: return null
|
|
748
|
+
val uri = Uri.parse(address)
|
|
749
|
+
if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null) return null
|
|
750
|
+
// Current STX releases preserve unknown HTML/binding spellings.
|
|
751
|
+
val rawSize = props.opt("imageSize") ?: props.opt("imagesize") ?: props.opt("image-size")
|
|
752
|
+
val shape = props.opt("imageShape") ?: props.opt("imageshape") ?: props.opt("image-shape")
|
|
753
|
+
val size = (rawSize as? Number)?.toDouble()?.takeIf { it.isFinite() } ?: 30.0
|
|
754
|
+
return CraftMarkerPhoto(address, size.coerceIn(16.0, 96.0).toFloat(), shape != "square")
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
internal class CraftMarkerPhotos(private val context: Context, private val changed: () -> Unit) {
|
|
760
|
+
private class Entry(val spec: CraftMarkerPhoto) {
|
|
761
|
+
var job: Future<*>? = null
|
|
762
|
+
var bitmap: Bitmap? = null
|
|
763
|
+
}
|
|
764
|
+
private val entries = mutableMapOf<String, Entry>()
|
|
765
|
+
private val executor = Executors.newFixedThreadPool(2)
|
|
766
|
+
private val main = Handler(Looper.getMainLooper())
|
|
767
|
+
|
|
768
|
+
fun bitmap(id: String, spec: CraftMarkerPhoto? = null): Bitmap? = entries[id]?.takeIf { spec == null || it.spec == spec }?.bitmap
|
|
769
|
+
|
|
770
|
+
fun reconcile(wanted: Map<String, CraftMarkerPhoto>) {
|
|
771
|
+
entries.keys.filter { entries[it]?.spec != wanted[it] }.forEach { entries.remove(it)?.job?.cancel(true) }
|
|
772
|
+
wanted.forEach { (id, spec) ->
|
|
773
|
+
if (entries.containsKey(id)) return@forEach
|
|
774
|
+
val entry = Entry(spec)
|
|
775
|
+
entries[id] = entry
|
|
776
|
+
entry.job = executor.submit {
|
|
777
|
+
val bitmap = runCatching { load(spec) }.getOrNull()
|
|
778
|
+
main.post {
|
|
779
|
+
if (entries[id] !== entry) return@post
|
|
780
|
+
entry.bitmap = bitmap
|
|
781
|
+
entry.job = null
|
|
782
|
+
if (bitmap != null) changed()
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private fun load(spec: CraftMarkerPhoto): Bitmap? {
|
|
789
|
+
val kept = CraftNativeMedia.local(context, spec.address)
|
|
790
|
+
val bytes = if (kept != null) kept.inputStream().use(::readBounded) else download(spec.address)
|
|
791
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
792
|
+
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
793
|
+
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
|
794
|
+
val size = (spec.size * context.resources.displayMetrics.density).roundToInt().coerceAtLeast(1)
|
|
795
|
+
var sample = 1
|
|
796
|
+
while (bounds.outWidth / (sample * 2) >= size && bounds.outHeight / (sample * 2) >= size) sample *= 2
|
|
797
|
+
val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
|
|
798
|
+
try {
|
|
799
|
+
val result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
800
|
+
val scale = maxOf(size.toFloat() / source.width, size.toFloat() / source.height)
|
|
801
|
+
val matrix = Matrix().apply {
|
|
802
|
+
setScale(scale, scale)
|
|
803
|
+
postTranslate((size - source.width * scale) / 2, (size - source.height * scale) / 2)
|
|
804
|
+
}
|
|
805
|
+
val shader = android.graphics.BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { setLocalMatrix(matrix) }
|
|
806
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { this.shader = shader }
|
|
807
|
+
val canvas = Canvas(result)
|
|
808
|
+
if (spec.circle) canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
809
|
+
else canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), paint)
|
|
810
|
+
return result
|
|
811
|
+
} finally { source.recycle() }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private fun readBounded(input: java.io.InputStream): ByteArray {
|
|
815
|
+
val output = java.io.ByteArrayOutputStream()
|
|
816
|
+
val buffer = ByteArray(8192)
|
|
817
|
+
while (true) {
|
|
818
|
+
if (Thread.currentThread().isInterrupted) throw java.io.InterruptedIOException()
|
|
819
|
+
val count = input.read(buffer)
|
|
820
|
+
if (count < 0) return output.toByteArray()
|
|
821
|
+
require(output.size() + count <= 8 * 1024 * 1024) { "Marker image exceeds 8 MiB" }
|
|
822
|
+
output.write(buffer, 0, count)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private fun download(address: String): ByteArray {
|
|
827
|
+
var url = URL(address)
|
|
828
|
+
repeat(6) {
|
|
829
|
+
require(url.protocol.equals("https", true) && url.userInfo == null)
|
|
830
|
+
val connection = (url.openConnection() as HttpURLConnection).apply {
|
|
831
|
+
connectTimeout = 10_000; readTimeout = 10_000
|
|
832
|
+
instanceFollowRedirects = false; useCaches = false
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
when (connection.responseCode) {
|
|
836
|
+
200 -> return connection.inputStream.use(::readBounded)
|
|
837
|
+
301, 302, 303, 307, 308 -> url = URL(url, requireNotNull(connection.getHeaderField("Location")))
|
|
838
|
+
else -> error("Marker image request failed")
|
|
839
|
+
}
|
|
840
|
+
} finally { connection.disconnect() }
|
|
841
|
+
}
|
|
842
|
+
error("Too many marker image redirects")
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fun destroy() {
|
|
846
|
+
entries.values.forEach { it.job?.cancel(true) }
|
|
847
|
+
entries.clear()
|
|
848
|
+
executor.shutdownNow()
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
743
852
|
{{NATIVE_MAP_HOST}}
|
|
744
853
|
|
|
745
854
|
/** A finite celebration owned by a View, never by the JavaScript frame loop. */
|
|
@@ -4141,6 +4250,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4141
4250
|
val pendingCapabilities = mutableSetOf<String>()
|
|
4142
4251
|
val capabilityTimeouts = mutableMapOf<String, Runnable>()
|
|
4143
4252
|
val audio = CraftNativeAudio()
|
|
4253
|
+
val sounds = CraftNativeSounds(view.context)
|
|
4254
|
+
val soundRequestCancellers = mutableMapOf<String, () -> Unit>()
|
|
4255
|
+
/** Snapshot writes awaiting the writer thread; close rejects them once. */
|
|
4256
|
+
val pendingSyncResults = mutableSetOf<String>()
|
|
4144
4257
|
var active = true
|
|
4145
4258
|
var closing = false
|
|
4146
4259
|
private set
|
|
@@ -4156,6 +4269,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4156
4269
|
active = false
|
|
4157
4270
|
closing = true
|
|
4158
4271
|
audio.close()
|
|
4272
|
+
soundRequestCancellers.values.toList().forEach { it() }
|
|
4273
|
+
soundRequestCancellers.clear()
|
|
4274
|
+
sounds.close()
|
|
4159
4275
|
// Evaluation completion does not mean its outbound messages have
|
|
4160
4276
|
// reached the host. Close only after the ordered channel barrier,
|
|
4161
4277
|
// so onDestroy's final writes run before navigation continues.
|
|
@@ -4687,6 +4803,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4687
4803
|
activeScreen.view.onLayoutChanged = {
|
|
4688
4804
|
if (activeScreen.active) emitLayoutEvents(activeScreen)
|
|
4689
4805
|
}
|
|
4806
|
+
activeScreen.sounds.emit = { payload ->
|
|
4807
|
+
if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
|
|
4808
|
+
}
|
|
4690
4809
|
screen = activeScreen
|
|
4691
4810
|
CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
|
|
4692
4811
|
if (activeScreen.active) send(activeScreen, type, payload)
|
|
@@ -5064,6 +5183,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5064
5183
|
/** The bridge's capability names: Craft's core set and this host's services. */
|
|
5065
5184
|
private fun capabilityNames(): JSONArray = capabilities.names().apply {
|
|
5066
5185
|
services.names().forEach { name -> put(name) }
|
|
5186
|
+
put("audio")
|
|
5067
5187
|
put("viewFocus")
|
|
5068
5188
|
put("viewMeasure")
|
|
5069
5189
|
put("viewScroll")
|
|
@@ -5082,13 +5202,20 @@ class MainActivity : AppCompatActivity() {
|
|
|
5082
5202
|
/** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
|
|
5083
5203
|
private fun syncWrite(screen: Screen, payload: JSONObject) {
|
|
5084
5204
|
val id = payload.optString("id").takeIf { it.isNotBlank() }
|
|
5205
|
+
val store = payload.optString("store")
|
|
5206
|
+
if (id != null) screen.pendingSyncResults.add(id)
|
|
5085
5207
|
val reply: (Boolean) -> Unit = { ok ->
|
|
5086
|
-
if (id != null && screen.
|
|
5208
|
+
if (id != null && screen.pendingSyncResults.remove(id) && (screen.active || screen.closing)) {
|
|
5209
|
+
send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", ok))
|
|
5210
|
+
}
|
|
5087
5211
|
}
|
|
5088
5212
|
val key = payload.optString("key")
|
|
5089
5213
|
val value = if (payload.isNull("value")) null else payload.optString("value")
|
|
5090
|
-
when (
|
|
5091
|
-
"storage" -> if (key.isNotBlank())
|
|
5214
|
+
when (store) {
|
|
5215
|
+
"storage" -> if (key.isNotBlank()) {
|
|
5216
|
+
stores.setStorage(key, value, from = screen.capabilityScope)
|
|
5217
|
+
if (id != null) reply(true)
|
|
5218
|
+
} else if (id != null) reply(false)
|
|
5092
5219
|
"snapshots" -> if (payload.optBoolean("clear")) {
|
|
5093
5220
|
val keep = payload.optJSONArray("keep") ?: JSONArray()
|
|
5094
5221
|
stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
|
|
@@ -5329,6 +5456,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5329
5456
|
val token = "${screen.capabilityScope}/$id"
|
|
5330
5457
|
val pending = screen.pendingCapabilities.remove(token)
|
|
5331
5458
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
5459
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
5332
5460
|
if (pending) capabilities.cancel(token)
|
|
5333
5461
|
}
|
|
5334
5462
|
}
|
|
@@ -7254,6 +7382,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7254
7382
|
}
|
|
7255
7383
|
imageJobs[view] = imageExecutor.submit {
|
|
7256
7384
|
val bitmap = runCatching {
|
|
7385
|
+
// Prefer the authored URL's private/offline copy. Never put
|
|
7386
|
+
// these pixels in the public HTTP cache: media.clear owns them.
|
|
7387
|
+
CraftNativeMedia.local(this, uri)?.let { kept ->
|
|
7388
|
+
val decoded = kept.inputStream().use(BitmapFactory::decodeStream)
|
|
7389
|
+
return@runCatching decoded.takeIf { CraftNativeMedia.local(this, uri) == kept }
|
|
7390
|
+
}
|
|
7257
7391
|
val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
|
|
7258
7392
|
if (publicRequest) {
|
|
7259
7393
|
val bytes = imageCache.read(requestUri)
|
|
@@ -7508,6 +7642,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7508
7642
|
val timeout = Runnable {
|
|
7509
7643
|
if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
|
|
7510
7644
|
screen.capabilityTimeouts.remove(token)
|
|
7645
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
7511
7646
|
capabilities.cancel(token)
|
|
7512
7647
|
send(screen, "API_ERROR", JSONObject()
|
|
7513
7648
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
@@ -7525,6 +7660,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
7525
7660
|
val method = payload.optString("method")
|
|
7526
7661
|
val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
|
|
7527
7662
|
{ completion -> performView(screen, method, args, completion) }
|
|
7663
|
+
} else if (module == "Audio") {
|
|
7664
|
+
{ completion ->
|
|
7665
|
+
val id = args.optString(0)
|
|
7666
|
+
performAudio(screen, method, args, completion)
|
|
7667
|
+
if (method == "play" && id.isNotBlank() && screen.pendingCapabilities.contains(token)) {
|
|
7668
|
+
screen.soundRequestCancellers[token] = screen.sounds.cancellation(id)
|
|
7669
|
+
}
|
|
7670
|
+
}
|
|
7528
7671
|
} else if (services.handles(module)) {
|
|
7529
7672
|
{ completion -> services.perform(module, method, args, completion) }
|
|
7530
7673
|
} else {
|
|
@@ -7542,6 +7685,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7542
7685
|
perform { result ->
|
|
7543
7686
|
if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
|
|
7544
7687
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
7688
|
+
screen.soundRequestCancellers.remove(token)
|
|
7545
7689
|
val response = JSONObject()
|
|
7546
7690
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
7547
7691
|
.put("requestId", id)
|
|
@@ -7551,6 +7695,27 @@ class MainActivity : AppCompatActivity() {
|
|
|
7551
7695
|
}
|
|
7552
7696
|
}
|
|
7553
7697
|
|
|
7698
|
+
private fun performAudio(
|
|
7699
|
+
screen: Screen,
|
|
7700
|
+
method: String,
|
|
7701
|
+
args: JSONArray,
|
|
7702
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
7703
|
+
) {
|
|
7704
|
+
val id = args.optString(0)
|
|
7705
|
+
when (method) {
|
|
7706
|
+
"play" -> {
|
|
7707
|
+
val address = args.optString(1)
|
|
7708
|
+
screen.sounds.play(id, address, args.optJSONObject(2) ?: JSONObject(), completion)
|
|
7709
|
+
}
|
|
7710
|
+
"pause" -> completion(CraftNativeCapabilityResult(data = screen.sounds.pause(id)))
|
|
7711
|
+
"resume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.resume(id)))
|
|
7712
|
+
"setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id, args.optDouble(1, Double.NaN), args.optJSONObject(2)?.optDouble("fade", 0.0) ?: 0.0)))
|
|
7713
|
+
"stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, args.optJSONObject(1)?.optDouble("fadeOut", 0.0) ?: 0.0)))
|
|
7714
|
+
"stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(args.optJSONObject(0)?.optDouble("fadeOut", 0.0) ?: 0.0)))
|
|
7715
|
+
else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Audio method"))
|
|
7716
|
+
}
|
|
7717
|
+
}
|
|
7718
|
+
|
|
7554
7719
|
/** View operations are screen-owned: node ids only exist while a route is mounted. */
|
|
7555
7720
|
private fun performView(
|
|
7556
7721
|
screen: Screen,
|
|
@@ -8165,6 +8330,13 @@ class MainActivity : AppCompatActivity() {
|
|
|
8165
8330
|
|
|
8166
8331
|
private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
|
|
8167
8332
|
if (!screen.active) { screen.close(done); return }
|
|
8333
|
+
// A snapshot writer may still be replacing a file after the isolate
|
|
8334
|
+
// starts teardown. Reject its promise before the port is closed; the
|
|
8335
|
+
// worker's later completion sees the id was already consumed.
|
|
8336
|
+
screen.pendingSyncResults.toList().forEach { id ->
|
|
8337
|
+
send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", false))
|
|
8338
|
+
}
|
|
8339
|
+
screen.pendingSyncResults.clear()
|
|
8168
8340
|
CraftNativeHub.unregister(screen.capabilityScope)
|
|
8169
8341
|
screen.timers.values.forEach(capabilityHandler::removeCallbacks)
|
|
8170
8342
|
screen.timers.clear()
|
package/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.360";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
|
@@ -412,7 +412,9 @@ class CraftEventManager {
|
|
|
412
412
|
/// The app script's delivery, once its bundle has run.
|
|
413
413
|
private var appScriptSink: ((String, [String: Any]) -> Void)?
|
|
414
414
|
|
|
415
|
-
|
|
415
|
+
// Internal construction also allows isolated event-delivery tests without
|
|
416
|
+
// replacing the running app's singleton listener.
|
|
417
|
+
init() {}
|
|
416
418
|
|
|
417
419
|
func setWebView(_ webView: WKWebView) {
|
|
418
420
|
self.webView = webView
|
|
@@ -668,7 +670,6 @@ struct CraftApp: App {
|
|
|
668
670
|
CraftColorSchemeObserver()
|
|
669
671
|
CraftHybridRoot(config: appState.config)
|
|
670
672
|
.ignoresSafeArea()
|
|
671
|
-
CraftTabBarView()
|
|
672
673
|
CraftSplashView(background: appState.config.resolvedBackgroundColor)
|
|
673
674
|
}
|
|
674
675
|
// The keyboard covers the tab bar, as it does UIKit's: the
|
|
@@ -88,8 +88,13 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
private func startScan() {
|
|
91
|
-
guard scanCompletion != nil else { return }
|
|
92
|
-
|
|
91
|
+
guard scanCompletion != nil, let central else { return }
|
|
92
|
+
// A device connected by another app may have stopped advertising.
|
|
93
|
+
// This lookup does not change its system pairing or connection.
|
|
94
|
+
for found in central.retrieveConnectedPeripherals(withServices: scanServices) {
|
|
95
|
+
remember(found, name: found.name ?? "", rssi: nil)
|
|
96
|
+
}
|
|
97
|
+
central.scanForPeripherals(withServices: scanServices, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
private func finishScan() {
|
|
@@ -122,11 +127,18 @@ final class CraftBluetooth: NSObject, CBCentralManagerDelegate, CBPeripheralDele
|
|
|
122
127
|
}
|
|
123
128
|
|
|
124
129
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
|
125
|
-
guard scanCompletion != nil, devices.count < 64 else { return }
|
|
126
130
|
let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? peripheral.name ?? ""
|
|
131
|
+
remember(peripheral, name: name, rssi: RSSI.intValue)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func remember(_ peripheral: CBPeripheral, name: String, rssi: Int?) {
|
|
135
|
+
guard scanCompletion != nil else { return }
|
|
127
136
|
guard namePrefix.isEmpty || name.lowercased().hasPrefix(namePrefix.lowercased()) else { return }
|
|
128
137
|
let id = peripheral.identifier.uuidString
|
|
129
|
-
devices[id]
|
|
138
|
+
guard devices[id] != nil || devices.count < 64 else { return }
|
|
139
|
+
devices[id] = peripheral
|
|
140
|
+
if !name.isEmpty || names[id] == nil { names[id] = name }
|
|
141
|
+
if let rssi { strengths[id] = rssi }
|
|
130
142
|
}
|
|
131
143
|
|
|
132
144
|
private func connect(_ options: [String: Any], _ completion: @escaping Completion) {
|