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.
@@ -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.active) send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", ok))
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 (payload.optString("store")) {
5091
- "storage" -> if (key.isNotBlank()) stores.setStorage(key, value, from = screen.capabilityScope)
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.358";
3050
+ var version = "0.0.359";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.358";
378
+ var version = "0.0.359";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.358";
120
+ var version = "0.0.359";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -192,10 +192,12 @@ enum CraftNativeFiles {
192
192
  do {
193
193
  let url = try newFile(named: name)
194
194
  guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
195
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
195
196
  return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
196
197
  }
197
198
  CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
198
199
  guard CGImageDestinationFinalize(destination) else {
200
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
199
201
  return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
200
202
  }
201
203
  return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
@@ -731,11 +733,11 @@ enum CraftNativeMedia {
731
733
  lock.lock()
732
734
  defer { lock.unlock() }
733
735
  if let flight, inFlight[address]?.id != flight {
734
- return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
736
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
735
737
  }
736
738
  // A background transfer from before the cache was cleared (sign-out).
737
739
  if let stillWanted, !stillWanted() {
738
- return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
740
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
739
741
  }
740
742
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
741
743
  defer { try? fm.removeItem(at: staging) }
@@ -784,7 +786,7 @@ enum CraftNativeMedia {
784
786
  catch { failure = error }
785
787
  }
786
788
  lock.unlock()
787
- for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
789
+ for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))) }
788
790
  if let failure { throw failure }
789
791
  return removed
790
792
  }