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.
@@ -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. */
@@ -899,6 +1008,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
899
1008
  private var wrapReverse = false
900
1009
  private var unboundedWidth = false
901
1010
  private var authoredPadding = intArrayOf(0, 0, 0, 0)
1011
+ private var windowSafeAreaInsets = intArrayOf(0, 0, 0, 0)
902
1012
  private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
903
1013
  private var applyingPadding = false
904
1014
  private var safeAreaEdges = setOf("top", "right", "bottom", "left")
@@ -962,18 +1072,20 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
962
1072
  }
963
1073
 
964
1074
  fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
965
- val insets = intArrayOf(left, top, right, bottom)
966
- safeAreaPadding = intArrayOf(
967
- if ("left" in safeAreaEdges) insets[0] else 0,
968
- if ("top" in safeAreaEdges) insets[1] else 0,
969
- if ("right" in safeAreaEdges) insets[2] else 0,
970
- if ("bottom" in safeAreaEdges) insets[3] else 0,
971
- )
1075
+ windowSafeAreaInsets = intArrayOf(left, top, right, bottom)
972
1076
  applySafeAreaPadding()
973
1077
  invalidateMeasurements()
974
1078
  }
975
1079
 
976
1080
  private fun applySafeAreaPadding() {
1081
+ // Keep the unfiltered insets so changing edges takes effect immediately,
1082
+ // including restoring an edge without another platform inset dispatch.
1083
+ safeAreaPadding = intArrayOf(
1084
+ if ("left" in safeAreaEdges) windowSafeAreaInsets[0] else 0,
1085
+ if ("top" in safeAreaEdges) windowSafeAreaInsets[1] else 0,
1086
+ if ("right" in safeAreaEdges) windowSafeAreaInsets[2] else 0,
1087
+ if ("bottom" in safeAreaEdges) windowSafeAreaInsets[3] else 0,
1088
+ )
977
1089
  setPadding(authoredPadding[0], authoredPadding[1], authoredPadding[2], authoredPadding[3])
978
1090
  }
979
1091
 
@@ -4138,6 +4250,10 @@ class MainActivity : AppCompatActivity() {
4138
4250
  val pendingCapabilities = mutableSetOf<String>()
4139
4251
  val capabilityTimeouts = mutableMapOf<String, Runnable>()
4140
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>()
4141
4257
  var active = true
4142
4258
  var closing = false
4143
4259
  private set
@@ -4153,6 +4269,9 @@ class MainActivity : AppCompatActivity() {
4153
4269
  active = false
4154
4270
  closing = true
4155
4271
  audio.close()
4272
+ soundRequestCancellers.values.toList().forEach { it() }
4273
+ soundRequestCancellers.clear()
4274
+ sounds.close()
4156
4275
  // Evaluation completion does not mean its outbound messages have
4157
4276
  // reached the host. Close only after the ordered channel barrier,
4158
4277
  // so onDestroy's final writes run before navigation continues.
@@ -4684,6 +4803,9 @@ class MainActivity : AppCompatActivity() {
4684
4803
  activeScreen.view.onLayoutChanged = {
4685
4804
  if (activeScreen.active) emitLayoutEvents(activeScreen)
4686
4805
  }
4806
+ activeScreen.sounds.emit = { payload ->
4807
+ if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
4808
+ }
4687
4809
  screen = activeScreen
4688
4810
  CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
4689
4811
  if (activeScreen.active) send(activeScreen, type, payload)
@@ -5061,6 +5183,7 @@ class MainActivity : AppCompatActivity() {
5061
5183
  /** The bridge's capability names: Craft's core set and this host's services. */
5062
5184
  private fun capabilityNames(): JSONArray = capabilities.names().apply {
5063
5185
  services.names().forEach { name -> put(name) }
5186
+ put("audio")
5064
5187
  put("viewFocus")
5065
5188
  put("viewMeasure")
5066
5189
  put("viewScroll")
@@ -5079,13 +5202,20 @@ class MainActivity : AppCompatActivity() {
5079
5202
  /** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
5080
5203
  private fun syncWrite(screen: Screen, payload: JSONObject) {
5081
5204
  val id = payload.optString("id").takeIf { it.isNotBlank() }
5205
+ val store = payload.optString("store")
5206
+ if (id != null) screen.pendingSyncResults.add(id)
5082
5207
  val reply: (Boolean) -> Unit = { ok ->
5083
- 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
+ }
5084
5211
  }
5085
5212
  val key = payload.optString("key")
5086
5213
  val value = if (payload.isNull("value")) null else payload.optString("value")
5087
- when (payload.optString("store")) {
5088
- "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)
5089
5219
  "snapshots" -> if (payload.optBoolean("clear")) {
5090
5220
  val keep = payload.optJSONArray("keep") ?: JSONArray()
5091
5221
  stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
@@ -5147,7 +5277,7 @@ class MainActivity : AppCompatActivity() {
5147
5277
 
5148
5278
  private fun refreshScreenTheme(screen: Screen) {
5149
5279
  applyScreenBackground(screen)
5150
- screen.mutations.node("root")?.let { renderCommitted(screen, it) }
5280
+ screen.mutations.document()?.let { renderCommitted(screen, it) }
5151
5281
  screen.controls.values.mapNotNull {
5152
5282
  when (it) {
5153
5283
  is CraftNativeRefreshableFlatList -> it.list
@@ -5165,7 +5295,7 @@ class MainActivity : AppCompatActivity() {
5165
5295
  private fun updateSafeAreaViews() {
5166
5296
  if (!::root.isInitialized) return
5167
5297
  val active = visibleScreen ?: screens.lastOrNull { it.active }
5168
- val rootNode = active?.mutations?.node("root")
5298
+ val rootNode = active?.mutations?.document()
5169
5299
  val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
5170
5300
  root.setPadding(
5171
5301
  if (safeRoot) 0 else windowSafeInsets[0],
@@ -5184,7 +5314,7 @@ class MainActivity : AppCompatActivity() {
5184
5314
  * against zero insets.
5185
5315
  */
5186
5316
  private fun updateSafeAreaInsets(screen: Screen) {
5187
- val rootNode = screen.mutations.node("root")
5317
+ val rootNode = screen.mutations.document()
5188
5318
  val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
5189
5319
  val tree = screen.view.getChildAt(0) as? CraftNativeFlexLayout
5190
5320
  if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
@@ -5326,6 +5456,7 @@ class MainActivity : AppCompatActivity() {
5326
5456
  val token = "${screen.capabilityScope}/$id"
5327
5457
  val pending = screen.pendingCapabilities.remove(token)
5328
5458
  screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
5459
+ screen.soundRequestCancellers.remove(token)?.invoke()
5329
5460
  if (pending) capabilities.cancel(token)
5330
5461
  }
5331
5462
  }
@@ -5511,6 +5642,17 @@ class MainActivity : AppCompatActivity() {
5511
5642
  }
5512
5643
  }
5513
5644
 
5645
+ private fun updateStatusBarProps(screen: Screen, props: JSONObject) {
5646
+ val style = craftNativeOptionalString(props.opt("barStyle"))
5647
+ val hidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
5648
+ val changed = screen.statusBarStyle != style || screen.statusBarHidden != hidden
5649
+ screen.statusBarStyle = style
5650
+ screen.statusBarHidden = hidden
5651
+ // MUTATE can reconcile this node without committing the whole screen.
5652
+ // Retained screens keep their props until their next visible commit.
5653
+ if (changed && visibleScreen === screen) applyStatusBar(screen)
5654
+ }
5655
+
5514
5656
  private fun applyStatusBar(screen: Screen) {
5515
5657
  val controller = WindowCompat.getInsetsController(window, window.decorView)
5516
5658
  val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
@@ -5582,8 +5724,7 @@ class MainActivity : AppCompatActivity() {
5582
5724
  isLongClickable = selectable
5583
5725
  }
5584
5726
  "StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
5585
- screen.statusBarStyle = craftNativeOptionalString(props.opt("barStyle"))
5586
- screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
5727
+ updateStatusBarProps(screen, props)
5587
5728
  visibility = View.GONE
5588
5729
  }
5589
5730
  "RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
@@ -7241,6 +7382,12 @@ class MainActivity : AppCompatActivity() {
7241
7382
  }
7242
7383
  imageJobs[view] = imageExecutor.submit {
7243
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
+ }
7244
7391
  val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
7245
7392
  if (publicRequest) {
7246
7393
  val bytes = imageCache.read(requestUri)
@@ -7495,6 +7642,7 @@ class MainActivity : AppCompatActivity() {
7495
7642
  val timeout = Runnable {
7496
7643
  if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
7497
7644
  screen.capabilityTimeouts.remove(token)
7645
+ screen.soundRequestCancellers.remove(token)?.invoke()
7498
7646
  capabilities.cancel(token)
7499
7647
  send(screen, "API_ERROR", JSONObject()
7500
7648
  .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
@@ -7512,6 +7660,14 @@ class MainActivity : AppCompatActivity() {
7512
7660
  val method = payload.optString("method")
7513
7661
  val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
7514
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
+ }
7515
7671
  } else if (services.handles(module)) {
7516
7672
  { completion -> services.perform(module, method, args, completion) }
7517
7673
  } else {
@@ -7529,6 +7685,7 @@ class MainActivity : AppCompatActivity() {
7529
7685
  perform { result ->
7530
7686
  if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
7531
7687
  screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
7688
+ screen.soundRequestCancellers.remove(token)
7532
7689
  val response = JSONObject()
7533
7690
  .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
7534
7691
  .put("requestId", id)
@@ -7538,6 +7695,27 @@ class MainActivity : AppCompatActivity() {
7538
7695
  }
7539
7696
  }
7540
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
+
7541
7719
  /** View operations are screen-owned: node ids only exist while a route is mounted. */
7542
7720
  private fun performView(
7543
7721
  screen: Screen,
@@ -8152,6 +8330,13 @@ class MainActivity : AppCompatActivity() {
8152
8330
 
8153
8331
  private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
8154
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()
8155
8340
  CraftNativeHub.unregister(screen.capabilityScope)
8156
8341
  screen.timers.values.forEach(capabilityHandler::removeCallbacks)
8157
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.355";
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.355";
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.355";
120
+ var version = "0.0.359";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;