ReviewOS

also looking at this

craft-native/craft

fix(native): interaction, audio, document and checkpoint gaps stranded after #401

#415
Merged glennmichael123 wants to merge codex/issues-388-400-344 into main
19 files +987 -50
docs/guides/native-stx.mdmodified+34-0
Changes to docs/guides/native-stx.md
@@ -1391,6 +1391,40 @@ unchanged. `craft.files.download` resolves a non-2xx response as
13911391`{ ok: false, status, body }`; transport failures still reject with the native
13921392network error codes.
13931393
1394Both maintained native hosts implement `craft.files.pickFile`. Android maps the
1395shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies
1396each selected document into the same app-owned directory, and enforces the
1397shared 512 MiB default/per-file limit before publishing a descriptor. A
1398dismissed picker resolves with an empty array; a canceled or oversized copy
1399never leaves a partial file behind. The same cancellation cleanup applies while
1400Android is decoding a picked image or copying a video, so a request canceled
1401after the picker closes cannot publish or leak an app-owned file. Use this for
1402GPX, TCX, FIT, PDF, or other documents that are not photos or videos. On iOS,
1403failed photo JPEG creation or finalization also removes its staging directory,
1404so a rejected image import does not accumulate orphaned temporary files.
1405
1406`craft.audio` playback is screen-owned on both hosts. Android requests audio
1407focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`,
1408pauses on a transient interruption and resumes on focus gain, ducks during a
1409duckable interruption, and releases focus when the last sound ends or the
1410screen closes. iOS observes `AVAudioSession` interruptions and resumes sounds
1411only when the system grants the `shouldResume` option. A permanent Android
1412focus loss releases every player and sends each sound's error event; replacement,
1413decode failure, cancellation, interruption, and teardown also release their
1414player resources.
1415Control calls resolve `false` when their sound id is not owned by the screen,
1416matching the browser host instead of reporting a false success.
1417
1418Checkpoint names use the same bounded filename contract on both native hosts:
1419letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading
1420dot. Non-JSON values and invalid names are rejected before a write is posted;
1421the previous confirmed checkpoint remains available after a failed or
1422interrupted replacement. If a screen closes while a checkpoint is still being
1423written, its promise rejects instead of hanging; the host's later worker
1424completion cannot publish a second result for that request. iOS also reports a
1425failed result when its writer completes after the screen runtime is disposed or
1426has already been deallocated.
1427
13941428The `media` capability (iOS) keeps video and audio on the phone for offline,
13951429in Application Support rather than the temporary directory, left out of
13961430iCloud backups. `craft.media.keep(url, { headers })` downloads the file once
packages/android/fixtures/native-navigation/NativeCapabilitiesTest.ktmodified+97-0
Changes to packages/android/fixtures/native-navigation/NativeCapabilitiesTest.kt
@@ -1,6 +1,9 @@
11package dev.craft.navigationtest
22
33import android.content.Intent
4import android.media.AudioManager
5import android.media.MediaPlayer
6import androidx.test.core.app.ActivityScenario
47import androidx.test.ext.junit.runners.AndroidJUnit4
58import androidx.test.platform.app.InstrumentationRegistry
69import java.net.ServerSocket
@@ -43,6 +46,100 @@ class NativeCapabilitiesTest {
4346
4447 private val instrumentation = InstrumentationRegistry.getInstrumentation()
4548
49 // A real local PCM file keeps these playback regressions independent of
50 // networking, codecs downloaded by the device, and the user's media.
51 private fun silentWav(): java.io.File {
52 val samples = 8000 * 2
53 val bytes = java.nio.ByteBuffer.allocate(44 + samples * 2).order(java.nio.ByteOrder.LITTLE_ENDIAN)
54 bytes.put("RIFF".toByteArray()).putInt(36 + samples * 2).put("WAVEfmt ".toByteArray())
55 bytes.putInt(16).putShort(1.toShort()).putShort(1.toShort()).putInt(8000).putInt(16000)
56 bytes.putShort(2.toShort()).putShort(16.toShort()).put("data".toByteArray()).putInt(samples * 2)
57 return java.io.File(instrumentation.targetContext.cacheDir, "sound-${UUID.randomUUID()}.wav").apply { writeBytes(bytes.array()) }
58 }
59
60 private fun sound(service: CraftNativeSounds): Any =
61 (CraftNativeSounds::class.java.getDeclaredField("sounds").apply { isAccessible = true }.get(service) as Map<*, *>)["tone"]!!
62
63 private fun field(sound: Any, name: String): Any? =
64 sound.javaClass.getDeclaredField(name).apply { isAccessible = true }.get(sound)
65
66 private fun focus(service: CraftNativeSounds, change: Int) {
67 CraftNativeSounds::class.java.getDeclaredMethod("onAudioFocusChange", Int::class.javaPrimitiveType)
68 .apply { isAccessible = true }.invoke(service, change)
69 }
70
71 @Test
72 fun replacingAPreparingSoundSettlesItAndItsOldCancellationCannotStopTheReplacement() {
73 val file = silentWav()
74 try {
75 ActivityScenario.launch(MainActivity::class.java).use { scenario ->
76 lateinit var service: CraftNativeSounds
77 val ready = CountDownLatch(1)
78 var replacement: CraftNativeCapabilityResult? = null
79 var cancelled = 0
80 try {
81 scenario.onActivity { activity ->
82 service = CraftNativeSounds(activity)
83 service.play("tone", file.toURI().toString(), JSONObject()) {
84 assertEquals("CANCELLED", it.code)
85 cancelled++
86 }
87 val oldCancel = service.cancellation("tone")
88 service.play("tone", file.toURI().toString(), JSONObject().put("loop", true)) {
89 replacement = it
90 ready.countDown()
91 }
92 assertEquals(1, cancelled)
93 oldCancel()
94 }
95 assertTrue("replacement did not prepare", ready.await(10, TimeUnit.SECONDS))
96 assertNull(replacement?.message, replacement?.code)
97 scenario.onActivity {
98 assertTrue((field(sound(service), "player") as MediaPlayer).isPlaying)
99 assertEquals(1, cancelled)
100 }
101 } finally { scenario.onActivity { service.close() } }
102 }
103 } finally { file.delete() }
104 }
105
106 @Test
107 fun explicitPauseSurvivesFocusGainAndVolumeChangesStayCurrent() {
108 val file = silentWav()
109 try {
110 ActivityScenario.launch(MainActivity::class.java).use { scenario ->
111 lateinit var service: CraftNativeSounds
112 val ready = CountDownLatch(1)
113 var result: CraftNativeCapabilityResult? = null
114 try {
115 scenario.onActivity { activity ->
116 service = CraftNativeSounds(activity)
117 service.play("tone", file.toURI().toString(), JSONObject().put("loop", true).put("volume", 0.8).put("fadeIn", 1000)) {
118 result = it
119 ready.countDown()
120 }
121 assertEquals(0f, field(sound(service), "volume"))
122 }
123 assertTrue("sound did not prepare", ready.await(10, TimeUnit.SECONDS))
124 assertNull(result?.message, result?.code)
125 scenario.onActivity {
126 assertTrue(service.setVolume("tone", 0.25, 0.0))
127 assertEquals(0.25f, field(sound(service), "volume"))
128 focus(service, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK)
129 focus(service, AudioManager.AUDIOFOCUS_GAIN)
130 assertEquals(0.25f, field(sound(service), "volume"))
131 focus(service, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT)
132 assertTrue(service.pause("tone"))
133 focus(service, AudioManager.AUDIOFOCUS_GAIN)
134 assertFalse((field(sound(service), "player") as MediaPlayer).isPlaying)
135 assertTrue(service.resume("tone"))
136 assertTrue((field(sound(service), "player") as MediaPlayer).isPlaying)
137 }
138 } finally { scenario.onActivity { service.close() } }
139 }
140 } finally { file.delete() }
141 }
142
46143 @Test
47144 fun rejectedSnapshotReplacementPreservesDiskCacheAndTheOtherIsolates() {
48145 val base = instrumentation.targetContext
packages/android/src/index.test.tsmodified+50-0
Changes to packages/android/src/index.test.ts
@@ -201,6 +201,10 @@ describe('Craft Android builder', () => {
201201 expect(refreshableFlatList).toContain('isRefreshing = false')
202202 expect(flatList).toContain('fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit)')
203203 expect(nativeActivity).toContain('setKeyboardDismissMode(craftNativeOptionalString(props.opt("keyboardDismissMode")).orEmpty())')
204 expect(nativeActivity).toContain('private val frameCallback = android.view.Choreographer.FrameCallback')
205 expect(nativeActivity).toContain('android.view.Choreographer.getInstance().postFrameCallback(frameCallback)')
206 expect(nativeActivity).toContain('android.view.Choreographer.getInstance().removeFrameCallback(frameCallback)')
207 expect(nativeActivity).toContain('requestAnimationFrame')
204208 expect(flatList).toContain('renderedLayout?.height ?: LayoutParams.WRAP_CONTENT')
205209 expect(nativeActivity).toContain('val nestedLists = refreshableLists.map { it.list }.toSet()')
206210 expect(nativeActivity).toContain('released.addAll(nestedLists)')
@@ -716,6 +720,9 @@ describe('Craft Android builder', () => {
716720 expect(native).toContain('showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")')
717721 expect(native).toContain('screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)')
718722 expect(native).toContain('screen.pendingCapabilities.forEach(capabilities::cancel)')
723 expect(native).toContain('screen.pendingSyncResults.toList().forEach')
724 expect(native).toContain('screen.pendingSyncResults.clear()')
725 expect(native).toContain('screen.pendingSyncResults.remove(id)')
719726 expect(native).toContain('screen.close {')
720727 expect(native).toContain('imageJobs.values.forEach { it.cancel(true) }')
721728 expect(native).toContain('imageExecutor.shutdownNow()')
@@ -785,6 +792,49 @@ describe('Craft Android builder', () => {
785792 expect(native).toContain('control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }')
786793 })
787794
795 it('gives native screens screen-owned audio playback with cancellation and teardown', async () => {
796 const output = createTempDir('craft-android-native-audio-')
797 await init({ name: 'NativeAudio', packageName: 'org.example.nativeaudio', output, runtimeDir: null, config: { renderer: 'native' } })
798 const root = join(output, 'app/src/main/java/org/example/nativeaudio')
799 const sounds = readFileSync(join(root, 'CraftNativeSounds.kt'), 'utf8')
800 const native = readFileSync(join(root, 'MainActivity.kt'), 'utf8')
801 expect(sounds).toContain('setOnPreparedListener')
802 expect(sounds).toContain('setOnCompletionListener')
803 expect(sounds).toContain('setOnErrorListener')
804 expect(sounds).toContain('sound.player.release()')
805 expect(sounds).toContain('CraftNativeMedia.local(context, text)')
806 expect(sounds).toContain('AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)')
807 expect(sounds).toContain('AUDIO_FOCUS_DENIED')
808 expect(sounds).toContain('AUDIOFOCUS_LOSS_TRANSIENT')
809 expect(sounds).toContain('AudioManager.AUDIOFOCUS_GAIN')
810 expect(sounds).toContain('abandonAudioFocusRequest')
811 expect(native).toContain('put("audio")')
812 expect(native).toContain('screen.soundRequestCancellers[token]')
813 expect(native).toContain('sounds.close()')
814 expect(native).toContain('"SOUND_EVENT"')
815 })
816
817 it('gives native screens an app-owned general document picker', async () => {
818 const output = createTempDir('craft-android-native-files-')
819 await init({ name: 'NativeFiles', packageName: 'org.example.nativefiles', output, runtimeDir: null, config: { renderer: 'native' } })
820 const root = join(output, 'app/src/main/java/org/example/nativefiles')
821 const capabilities = readFileSync(join(root, 'CraftNativeCapabilities.kt'), 'utf8')
822 expect(capabilities).toContain('"pickFile" -> pickFile(requestToken, args, completion)')
823 expect(capabilities).toContain('Intent.ACTION_OPEN_DOCUMENT')
824 expect(capabilities).toContain('Intent.EXTRA_ALLOW_MULTIPLE')
825 expect(capabilities).toContain('Intent.EXTRA_MIME_TYPES')
826 expect(capabilities).toContain('copyDocuments(pending.requestToken, uris, pending.fileLimit)')
827 expect(capabilities).toContain('FILE_TOO_LARGE')
828 expect(capabilities).toContain('CANCELLED: File selection was cancelled')
829 expect(capabilities).toContain('ownedFile(it)?.parentFile?.deleteRecursively()')
830 expect(capabilities).toContain('application/vnd.garmin.tcx+xml')
831 expect(capabilities).toContain('copyVideo(pending.requestToken, it, pending.videoLimit)')
832 expect(capabilities).toContain('CANCELLED: Video selection was cancelled')
833 expect(capabilities).toContain('copyUri(pending.requestToken, it, pending.maxPixels, pending.quality)')
834 expect(capabilities).toContain('networkExecutor.execute {')
835 expect(capabilities).toContain('file.parentFile?.deleteRecursively()')
836 })
837
788838 it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => {
789839 const output = createTempDir('craft-android-native-wallet-')
790840 await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } })
packages/android/src/index.tsmodified+1-0
Changes to packages/android/src/index.ts
@@ -900,6 +900,7 @@ import com.google.android.gms.pay.PayClient` : '')
900900 done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured"))
901901 }`
902902 : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))'))
903 write('CraftNativeSounds.kt.template', 'CraftNativeSounds.kt')
903904 if (config.enablePushNotifications) write('CraftNativeMessagingService.kt.template', 'CraftNativeMessagingService.kt')
904905 else rmSync(join(javaDir, 'CraftNativeMessagingService.kt'), { force: true })
905906 // Files a screen shares (craft.files' copies, in the cache) go to other apps through this provider.
packages/android/src/native-shell.test.tsmodified+8-0
Changes to packages/android/src/native-shell.test.ts
@@ -100,6 +100,14 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca
100100 expect(app.context.craft.snapshots.get('campaign')).toEqual({ tick: 24 })
101101 })
102102
103 it('rejects invalid checkpoint names and values before posting a write', async () => {
104 const app = isolate()
105 await expect(app.context.craft.snapshots.set('../escape', { tick: 1 })).rejects.toThrow(/Snapshot names are letters/)
106 await expect(app.context.craft.snapshots.set('valid', undefined)).rejects.toThrow(/is not JSON/)
107 expect(app.context.craft.snapshots.get('../escape')).toBeNull()
108 expect(app.posted.filter(message => message.type === 'SYNC_WRITE')).toHaveLength(0)
109 })
110
103111 it('follows another isolate\'s writes and tells the screen a snapshot changed', () => {
104112 const app = isolate({ snapshots: { header: '{"userId":1}' } })
105113 app.deliver({ type: 'SYNC_UPDATE', payload: { store: 'snapshots', key: 'header', value: '{"userId":2}' } })
packages/android/templates/CraftNativeCapabilities.kt.templatemodified+184-14
Changes to packages/android/templates/CraftNativeCapabilities.kt.template
@@ -112,6 +112,8 @@ internal class CraftNativeCapabilities(
112112 val quality: Float,
113113 val completion: (CraftNativeCapabilityResult) -> Unit,
114114 val videoLimit: Long = 0,
115 val selectionLimit: Int = 1,
116 val fileLimit: Long = 0,
115117 )
116118
117119 private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -218,6 +220,7 @@ internal class CraftNativeCapabilities(
218220 "Files" -> when (method) {
219221 "pickImage" -> pickImage(requestToken, args, completion)
220222 "pickVideo" -> pickVideo(requestToken, args, completion)
223 "pickFile" -> pickFile(requestToken, args, completion)
221224 "download", "write", "remove" -> networkExecutor.execute {
222225 complete(requestToken, files(requestToken, method, args), completion)
223226 }
@@ -512,9 +515,10 @@ internal class CraftNativeCapabilities(
512515 }
513516
514517 /** 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 {
516519 var target: File? = null
517520 return try {
521 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
518522 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
519523 if (it.moveToFirst()) it.getString(0) else null
520524 } ?: "video.mp4"
@@ -531,6 +535,7 @@ internal class CraftNativeCapabilities(
531535 while (true) {
532536 val count = source.read(buffer)
533537 if (count < 0) break
538 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
534539 size += count
535540 require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
536541 output.write(buffer, 0, count)
@@ -541,7 +546,9 @@ internal class CraftNativeCapabilities(
541546 success(descriptor(destination, type))
542547 } catch (error: Exception) {
543548 target?.parentFile?.deleteRecursively()
544 failure("READ_ERROR", error.message ?: "The video could not be read")
549 val message = error.message ?: "The video could not be read"
550 if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
551 else failure("READ_ERROR", message)
545552 }
546553 }
547554
@@ -585,6 +592,76 @@ internal class CraftNativeCapabilities(
585592 }
586593 }
587594
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
588665 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
589666 if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
590667 val pending = pendingFilePick ?: return true
@@ -595,24 +672,45 @@ internal class CraftNativeCapabilities(
595672 }
596673 if (pending.source == "video") {
597674 networkExecutor.execute {
598 val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
675 val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
599676 ?: failure("PICKER_FAILED", "The selected video could not be read")
600677 complete(pending.requestToken, result, pending.completion)
601678 }
602679 return true
603680 }
604 val descriptor = if (pending.source == "camera") {
605 val bitmap = data?.extras?.get("data") as? Bitmap
606 bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
607 } else {
608 val uri = data?.data
609 uri?.let { copyUri(it, pending.maxPixels, pending.quality) }
681 if (pending.source == "file") {
682 networkExecutor.execute {
683 val uris = buildList {
684 data?.clipData?.let { clips ->
685 for (index in 0 until clips.itemCount) add(clips.getItemAt(index).uri)
686 }
687 data?.data?.let { add(it) }
688 }.distinct().take(pending.selectionLimit)
689 val result = copyDocuments(pending.requestToken, uris, pending.fileLimit)
690 complete(pending.requestToken, result, pending.completion)
691 }
692 return true
693 }
694 networkExecutor.execute {
695 val descriptor = if (pending.source == "camera") {
696 val bitmap = data?.extras?.get("data") as? Bitmap
697 bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg", pending.requestToken) }
698 } else {
699 val uri = data?.data
700 uri?.let { copyUri(pending.requestToken, it, pending.maxPixels, pending.quality) }
701 }
702 complete(
703 pending.requestToken,
704 descriptor?.let(::success) ?: if (cancelled.contains(pending.requestToken)) failure("CANCELLED", "Image selection was cancelled")
705 else failure("PICKER_FAILED", "The selected image could not be read"),
706 pending.completion,
707 )
610708 }
611 complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion)
612709 return true
613710 }
614711
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
616714 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
617715 if (it.moveToFirst()) it.getString(0) else null
618716 } ?: "photo"
@@ -623,10 +721,74 @@ internal class CraftNativeCapabilities(
623721 while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
624722 val options = BitmapFactory.Options().apply { inSampleSize = sample }
625723 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)
627725 }
628726
629 private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
727 private fun copyDocuments(requestToken: String, uris: List<Uri>, limit: Long): CraftNativeCapabilityResult {
728 if (uris.isEmpty()) return failure("PICKER_FAILED", "No document was selected")
729 val copied = JSONArray()
730 return try {
731 uris.forEach { copied.put(copyDocument(requestToken, it, limit)) }
732 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
733 success(copied)
734 } catch (error: Exception) {
735 for (index in 0 until copied.length()) {
736 copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.parentFile?.deleteRecursively() }
737 }
738 val message = error.message ?: "The selected document could not be read"
739 if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
740 else if (message.startsWith("FILE_TOO_LARGE:")) failure("FILE_TOO_LARGE", message.removePrefix("FILE_TOO_LARGE:").trim())
741 else failure("READ_ERROR", message)
742 }
743 }
744
745 private fun copyDocument(requestToken: String, uri: Uri, limit: Long): JSONObject {
746 var target: File? = null
747 return try {
748 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
749 if (it.moveToFirst()) it.getString(0) else null
750 } ?: "file"
751 val type = context.contentResolver.getType(uri) ?: documentMimeType(name)
752 val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
753 target = destination
754 val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The selected document could not be read")
755 var size = 0L
756 input.use { source ->
757 destination.outputStream().use { output ->
758 val buffer = ByteArray(64 * 1024)
759 while (true) {
760 val count = source.read(buffer)
761 if (count < 0) break
762 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
763 size += count
764 require(size <= limit) { "FILE_TOO_LARGE: Choose a file under ${limit / 1024 / 1024}MB." }
765 output.write(buffer, 0, count)
766 }
767 }
768 }
769 require(size > 0) { "Choose a nonempty file." }
770 descriptor(destination, type)
771 } catch (error: Exception) {
772 target?.parentFile?.deleteRecursively()
773 throw error
774 }
775 }
776
777 private fun documentMimeType(name: String): String = when (name.substringAfterLast('.', "").lowercase()) {
778 "gpx" -> "application/gpx+xml"
779 "tcx" -> "application/vnd.garmin.tcx+xml"
780 "json" -> "application/json"
781 "csv" -> "text/csv"
782 "txt" -> "text/plain"
783 "pdf" -> "application/pdf"
784 else -> "application/octet-stream"
785 }
786
787 private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String, requestToken: String? = null): JSONObject? {
788 if (requestToken != null && cancelled.contains(requestToken)) {
789 bitmap.recycle()
790 return null
791 }
630792 val longest = max(bitmap.width, bitmap.height)
631793 val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f
632794 val output = if (scale < 1f) Bitmap.createScaledBitmap(
@@ -643,7 +805,15 @@ internal class CraftNativeCapabilities(
643805 }.getOrDefault(false)
644806 if (output !== bitmap) output.recycle()
645807 bitmap.recycle()
646 return if (written) descriptor(file, "image/jpeg").put("width", width).put("height", height) else null
808 if (!written) {
809 file.parentFile?.deleteRecursively()
810 return null
811 }
812 if (requestToken != null && cancelled.contains(requestToken)) {
813 file.parentFile?.deleteRecursively()
814 return null
815 }
816 return descriptor(file, "image/jpeg").put("width", width).put("height", height)
647817 }
648818
649819 private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult {
packages/android/templates/CraftNativeShell.kt.templatemodified+20-5
Changes to packages/android/templates/CraftNativeShell.kt.template
@@ -373,6 +373,10 @@ internal class CraftNativeStores private constructor(
373373
374374 /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */
375375 fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) {
376 if (!isSnapshotName(name)) {
377 main.post { done(false) }
378 return
379 }
376380 writer.execute {
377381 val file = snapshotFile(name)
378382 val previous = snapshotCache[name]
@@ -422,6 +426,8 @@ internal class CraftNativeStores private constructor(
422426 CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from)
423427 }
424428
429 private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}"))
430
425431 private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json")
426432
427433 private fun snapshotName(file: File): String? =
@@ -659,6 +665,9 @@ internal object CraftNativePrelude {
659665 if (json === null || json === undefined) return null;
660666 try { return JSON.parse(json); } catch (error) { return null; }
661667 }
668 function validSnapshotName(name) {
669 return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name);
670 }
662671 var craft = globalThis.craft = globalThis.craft || {};
663672 function keep(name, extras) {
664673 var current = {};
@@ -681,12 +690,18 @@ internal object CraftNativePrelude {
681690 }
682691 });
683692 keep('snapshots', {
684 get: function(name) { return parsed(sync.snapshots[String(name)]); },
693 get: function(name) {
694 name = String(name);
695 return validSnapshotName(name) ? parsed(sync.snapshots[name]) : null;
696 },
685697 set: function(name, value) {
686 var json = value === null || value === undefined ? null : JSON.stringify(value);
687 if (json === null) delete sync.snapshots[String(name)];
688 else sync.snapshots[String(name)] = json;
689 return wait('snapshots.set', { store: 'snapshots', key: String(name), value: json });
698 name = String(name);
699 if (!validSnapshotName(name)) return Promise.reject(new TypeError('Snapshot names are letters, digits, ".", "_" and "-"'));
700 var json = value === null ? null : JSON.stringify(value);
701 if (json === undefined) return Promise.reject(new TypeError('Snapshot ' + name + ' is not JSON'));
702 if (json === null) delete sync.snapshots[name];
703 else sync.snapshots[name] = json;
704 return wait('snapshots.set', { store: 'snapshots', key: name, value: json });
690705 },
691706 clear: function(keepNames) {
692707 var names = Array.isArray(keepNames) ? keepNames.map(String) : [];
packages/android/templates/CraftNativeSounds.kt.templateadded+260-0
Changes to packages/android/templates/CraftNativeSounds.kt.template
@@ -0,0 +1,260 @@
1package
2
3import android.content.Context
4import android.media.AudioAttributes
5import android.media.AudioFocusRequest
6import android.media.AudioManager
7import android.media.MediaPlayer
8import android.net.Uri
9import android.os.Handler
10import android.os.Looper
11import org.json.JSONObject
12import java.io.File
13import kotlin.math.max
14import 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 */
23internal 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}
packages/android/templates/MainActivityNative.kt.templatemodified+60-3
Changes to packages/android/templates/MainActivityNative.kt.template
@@ -4250,6 +4250,10 @@ class MainActivity : AppCompatActivity() {
42504250 val pendingCapabilities = mutableSetOf<String>()
42514251 val capabilityTimeouts = mutableMapOf<String, Runnable>()
42524252 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>()
42534257 var active = true
42544258 var closing = false
42554259 private set
@@ -4265,6 +4269,9 @@ class MainActivity : AppCompatActivity() {
42654269 active = false
42664270 closing = true
42674271 audio.close()
4272 soundRequestCancellers.values.toList().forEach { it() }
4273 soundRequestCancellers.clear()
4274 sounds.close()
42684275 // Evaluation completion does not mean its outbound messages have
42694276 // reached the host. Close only after the ordered channel barrier,
42704277 // so onDestroy's final writes run before navigation continues.
@@ -4796,6 +4803,9 @@ class MainActivity : AppCompatActivity() {
47964803 activeScreen.view.onLayoutChanged = {
47974804 if (activeScreen.active) emitLayoutEvents(activeScreen)
47984805 }
4806 activeScreen.sounds.emit = { payload ->
4807 if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
4808 }
47994809 screen = activeScreen
48004810 CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
48014811 if (activeScreen.active) send(activeScreen, type, payload)
@@ -5173,6 +5183,7 @@ class MainActivity : AppCompatActivity() {
51735183 /** The bridge's capability names: Craft's core set and this host's services. */
51745184 private fun capabilityNames(): JSONArray = capabilities.names().apply {
51755185 services.names().forEach { name -> put(name) }
5186 put("audio")
51765187 put("viewFocus")
51775188 put("viewMeasure")
51785189 put("viewScroll")
@@ -5191,13 +5202,20 @@ class MainActivity : AppCompatActivity() {
51915202 /** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
51925203 private fun syncWrite(screen: Screen, payload: JSONObject) {
51935204 val id = payload.optString("id").takeIf { it.isNotBlank() }
5205 val store = payload.optString("store")
5206 if (id != null) screen.pendingSyncResults.add(id)
51945207 val reply: (Boolean) -> Unit = { ok ->
5195 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 }
51965211 }
51975212 val key = payload.optString("key")
51985213 val value = if (payload.isNull("value")) null else payload.optString("value")
5199 when (payload.optString("store")) {
5200 "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)
52015219 "snapshots" -> if (payload.optBoolean("clear")) {
52025220 val keep = payload.optJSONArray("keep") ?: JSONArray()
52035221 stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
@@ -5438,6 +5456,7 @@ class MainActivity : AppCompatActivity() {
54385456 val token = "${screen.capabilityScope}/$id"
54395457 val pending = screen.pendingCapabilities.remove(token)
54405458 screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
5459 screen.soundRequestCancellers.remove(token)?.invoke()
54415460 if (pending) capabilities.cancel(token)
54425461 }
54435462 }
@@ -7623,6 +7642,7 @@ class MainActivity : AppCompatActivity() {
76237642 val timeout = Runnable {
76247643 if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
76257644 screen.capabilityTimeouts.remove(token)
7645 screen.soundRequestCancellers.remove(token)?.invoke()
76267646 capabilities.cancel(token)
76277647 send(screen, "API_ERROR", JSONObject()
76287648 .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
@@ -7640,6 +7660,14 @@ class MainActivity : AppCompatActivity() {
76407660 val method = payload.optString("method")
76417661 val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
76427662 { 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 }
76437671 } else if (services.handles(module)) {
76447672 { completion -> services.perform(module, method, args, completion) }
76457673 } else {
@@ -7657,6 +7685,7 @@ class MainActivity : AppCompatActivity() {
76577685 perform { result ->
76587686 if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
76597687 screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
7688 screen.soundRequestCancellers.remove(token)
76607689 val response = JSONObject()
76617690 .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
76627691 .put("requestId", id)
@@ -7666,6 +7695,27 @@ class MainActivity : AppCompatActivity() {
76667695 }
76677696 }
76687697
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
76697719 /** View operations are screen-owned: node ids only exist while a route is mounted. */
76707720 private fun performView(
76717721 screen: Screen,
@@ -8280,6 +8330,13 @@ class MainActivity : AppCompatActivity() {
82808330
82818331 private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
82828332 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()
82838340 CraftNativeHub.unregister(screen.capabilityScope)
82848341 screen.timers.values.forEach(capabilityHandler::removeCallbacks)
82858342 screen.timers.clear()
packages/ios/README.mdmodified+1-1
Changes to packages/ios/README.md
@@ -174,7 +174,7 @@ and linear gradients are applied by UIKit with the same style keys as Android;
174174`elevation` remains Android-specific. Gradient layers follow the native view's
175175bounds and corners through layout changes.
176176
177The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
177The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `SegmentedControl`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. Dynamic `craft.shortcuts.set`/`clear` are not installed by the all-native host yet; configure static `appShortcuts` in `craft.config.json` to publish home-screen quick actions. Runtime shortcut registration remains tracked in #412. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
178178
179179`FlatList` rows are authored as keyed `:for` child rows in native STX; the
180180maintained protocol does not serialize `data`/`renderItem` callbacks.
packages/ios/fixtures/native-navigation/Home.stxmodified+15-0
Changes to packages/ios/fixtures/native-navigation/Home.stx
@@ -15,6 +15,8 @@ const notesEndEditings = state(0)
1515const status = state('')
1616const pressIns = state(0)
1717const pressOuts = state(0)
18const nestedButtonTaps = state(0)
19const nestedParentTaps = state(0)
1820const linkAccessibilityDisabled = state(false)
1921const panelAccessibilityDisabled = state(false)
2022const longPressStatus = state('')
@@ -403,6 +405,14 @@ function pressNativePressableOut() {
403405 pressOuts.update(value => value + 1)
404406}
405407
408function pressNestedButton() {
409 nestedButtonTaps.update(value => value + 1)
410}
411
412function pressNestedParent() {
413 nestedParentTaps.update(value => value + 1)
414}
415
406416function pressPanelLong() {
407417 longPressStatus.set('panel long pressed')
408418}
@@ -852,6 +862,11 @@ function openDetailsThenUpdateBackground() {
852862 <Pressable testID="native-linked-pressable" href="/linked-details" style={{"height":24}}><Text>Linked pressable</Text></Pressable>
853863 <Pressable testID="native-raw-pressable" style={{"height":24,"color":"#ef4444","fontSize":18,"textTransform":"uppercase"}}>Raw pressable</Pressable>
854864 <Text testID="pressable-lifecycle" :text="'Press lifecycle: ' + pressIns + '/' + pressOuts" />
865 <Pressable testID="nested-press-parent" @click="pressNestedParent()" style={{"height":44}}>
866 <Button testID="nested-press-button" @click="pressNestedButton()">Nested press</Button>
867 </Pressable>
868 <Text testID="nested-button-taps" :text="'Nested button taps: ' + nestedButtonTaps" />
869 <Text testID="nested-parent-taps" :text="'Nested parent taps: ' + nestedParentTaps" />
855870 <Pressable testID="disabled-pressable" disabled @click="pressNativePressable()" style={{"height":24}}><Text>Disabled pressable</Text></Pressable>
856871 <Button testID="toggle-panel-accessibility" @click="togglePanelAccessibility()">Toggle panel accessibility</Button>
857872 <View testID="long-press-panel" accessibilityState={longPressAccessibilityState()} @longpress="pressPanelLong()" style={{"height":24}}><Text>Long press panel</Text></View>
packages/ios/fixtures/native-navigation/NativeNavigationUITests.swiftmodified+20-0
Changes to packages/ios/fixtures/native-navigation/NativeNavigationUITests.swift
@@ -816,6 +816,26 @@ final class NativeNavigationUITests: XCTestCase {
816816 XCTAssertTrue(scroll.exists, "keyed reconciliation replaced the list scroll host")
817817 }
818818
819 func testNestedButtonKeepsItsActionInsidePressable() throws {
820 let app = XCUIApplication()
821 app.launch()
822 let rootScroll = app.scrollViews["native-root-scroll"]
823 XCTAssertTrue(rootScroll.waitForExistence(timeout: 15))
824 let nestedButton = app.buttons["nested-press-button"]
825 XCTAssertTrue(nestedButton.waitForExistence(timeout: 15))
826 reveal(nestedButton, in: rootScroll)
827
828 let nestedButtonTaps = app.staticTexts["nested-button-taps"]
829 XCTAssertTrue(nestedButtonTaps.waitForExistence(timeout: 5))
830 XCTAssertEqual(nestedButtonTaps.label, "Nested button taps: 0")
831
832 nestedButton.tap()
833 XCTAssertTrue(waitForLabel(nestedButtonTaps, "Nested button taps: 1"), "nested Button action did not arrive")
834
835 nestedButton.tap()
836 XCTAssertTrue(waitForLabel(nestedButtonTaps, "Nested button taps: 2"), "nested Button action was lost or duplicated")
837 }
838
819839 func testPushNativeBackSwipeReplaceAndRetainedHomeState() throws {
820840 let app = XCUIApplication()
821841 app.launch()
packages/ios/fixtures/native-screen/NativeAudioUnitTests.swiftmodified+55-0
Changes to packages/ios/fixtures/native-screen/NativeAudioUnitTests.swift
@@ -1,4 +1,5 @@
11import JavaScriptCore
2import AVFoundation
23import XCTest
34@testable import NativeRender
45
@@ -7,6 +8,60 @@ import XCTest
78/// samples the engine would play.
89@MainActor
910final class NativeAudioUnitTests: XCTestCase {
11 private func withSound(_ test: (CraftNativeSounds) -> Void) throws {
12 let file = FileManager.default.temporaryDirectory.appendingPathComponent("sound-\(UUID().uuidString).wav")
13 defer { try? FileManager.default.removeItem(at: file) }
14 let format = try XCTUnwrap(AVAudioFormat(standardFormatWithSampleRate: 8000, channels: 1))
15 let buffer = try XCTUnwrap(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 16000))
16 buffer.frameLength = 16000
17 buffer.floatChannelData?[0].initialize(repeating: 0, count: 16000)
18 do {
19 let output = try AVAudioFile(forWriting: file, settings: format.settings)
20 try output.write(from: buffer)
21 }
22 let service = CraftNativeSounds()
23 defer { service.close() }
24 service.play(id: "tone", url: file, options: ["loop": true]) { _ in }
25 test(service)
26 }
27
28 private func interrupt(_ type: AVAudioSession.InterruptionType, resume: Bool = false) {
29 NotificationCenter.default.post(name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance(), userInfo: [
30 AVAudioSessionInterruptionTypeKey: NSNumber(value: type.rawValue),
31 AVAudioSessionInterruptionOptionKey: NSNumber(value: resume ? AVAudioSession.InterruptionOptions.shouldResume.rawValue : 0),
32 ])
33 }
34
35 func testSoundWithoutAutomaticResumePermissionCanBeResumedExplicitly() throws {
36 try withSound { service in
37 interrupt(.began)
38 interrupt(.ended)
39 // Another unrelated interruption must not auto-resume this handle.
40 interrupt(.began)
41 interrupt(.ended, resume: true)
42 XCTAssertTrue(service.resume(id: "tone"))
43 XCTAssertFalse(service.resume(id: "tone"))
44 }
45 }
46
47 func testExplicitSoundPauseSurvivesInterruptionResumePermission() throws {
48 try withSound { service in
49 interrupt(.began)
50 XCTAssertTrue(service.pause(id: "tone"))
51 interrupt(.ended, resume: true)
52 XCTAssertTrue(service.resume(id: "tone"))
53 }
54 }
55
56 func testSoundAutomaticallyResumesOnlyWithSystemPermission() throws {
57 try withSound { service in
58 interrupt(.began)
59 interrupt(.ended, resume: true)
60 XCTAssertFalse(service.resume(id: "tone"), "already resumed automatically")
61 XCTAssertFalse(service.resume(id: "missing"))
62 }
63 }
64
1065 private final class VoiceRecorder: CraftVoiceRecorder {
1166 private let lock = NSLock()
1267 private var records = 0
packages/ios/fixtures/native-screen/NativeRenderUnitTests.swiftmodified+50-0
Changes to packages/ios/fixtures/native-screen/NativeRenderUnitTests.swift
@@ -3198,6 +3198,28 @@ final class NativeRenderUnitTests: XCTestCase {
31983198 XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled))
31993199 }
32003200
3201 func testNestedPressRecognizersGiveTheInnermostActionFirstRefusal() throws {
3202 let controller = CraftNativeScreenController(config: CraftConfig())
3203 controller.loadViewIfNeeded()
3204 controller.render(document([[
3205 "type": "Pressable", "props": ["key": "outer"], "events": ["onPress": "outerPressed"],
3206 "children": [[
3207 "type": "Pressable", "props": ["key": "inner"], "events": ["onPress": "innerPressed"],
3208 "children": [["type": "Text", "props": ["key": "label"], "children": ["Tap"]]],
3209 ]],
3210 ]]))
3211
3212 let outer = try XCTUnwrap(find(UIView.self, key: "outer", below: controller.view))
3213 let inner = try XCTUnwrap(find(UIView.self, key: "inner", below: controller.view))
3214 let outerTap = try XCTUnwrap(outer.gestureRecognizers?.first { $0 is UITapGestureRecognizer })
3215 let innerTap = try XCTUnwrap(inner.gestureRecognizers?.first { $0 is UITapGestureRecognizer })
3216 let delegate = try XCTUnwrap(outerTap.delegate as? CraftPressGestureDelegate)
3217 XCTAssertTrue(innerTap.delegate === CraftPressGestureDelegate.shared)
3218 XCTAssertTrue(delegate.gestureRecognizer(outerTap, shouldRequireFailureOf: innerTap))
3219 XCTAssertFalse(delegate.gestureRecognizer(innerTap, shouldRequireFailureOf: outerTap))
3220 XCTAssertFalse(outerTap.cancelsTouchesInView)
3221 }
3222
32013223 func testModalCloseRequestAndDismissAreIndependentVisibilityEvents() throws {
32023224 let controller = CraftNativeScreenController(config: CraftConfig())
32033225 controller.loadViewIfNeeded()
@@ -3749,6 +3771,34 @@ final class NativeRenderUnitTests: XCTestCase {
37493771 XCTAssertEqual(authored.accessibilityContainerType, .none)
37503772 }
37513773
3774 func testLabelledModalAndDrawerExposeTheirInteractiveChildren() throws {
3775 let controller = CraftNativeScreenController(config: CraftConfig())
3776 controller.loadViewIfNeeded()
3777 controller.render(document([
3778 [
3779 "type": "Modal",
3780 "props": ["key": "labelled-modal", "visible": true, "accessibilityLabel": "Outer dialog"],
3781 "children": [["type": "Button", "props": ["key": "modal-action"], "children": ["Open nested"]]],
3782 ],
3783 [
3784 "type": "Drawer",
3785 "props": ["key": "labelled-drawer", "accessibilityLabel": "Filters"],
3786 "children": [["type": "Button", "props": ["key": "drawer-action"], "children": ["Apply"]]],
3787 ],
3788 ]))
3789
3790 let modal = try XCTUnwrap(find(UIView.self, key: "labelled-modal", below: controller.view))
3791 let drawer = try XCTUnwrap(find(UIView.self, key: "labelled-drawer", below: controller.view))
3792 XCTAssertFalse(modal.isAccessibilityElement)
3793 XCTAssertFalse(drawer.isAccessibilityElement)
3794 XCTAssertEqual(modal.accessibilityLabel, "Outer dialog")
3795 XCTAssertEqual(drawer.accessibilityLabel, "Filters")
3796 XCTAssertEqual(modal.accessibilityContainerType, .semanticGroup)
3797 XCTAssertEqual(drawer.accessibilityContainerType, .semanticGroup)
3798 XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "modal-action", below: modal)).isAccessibilityElement)
3799 XCTAssertTrue(try XCTUnwrap(find(UIButton.self, key: "drawer-action", below: drawer)).isAccessibilityElement)
3800 }
3801
37523802 func testSwitchAccessibilityMatchesUIKitAcrossValueAndEnabledChanges() throws {
37533803 let controller = CraftNativeScreenController(config: CraftConfig())
37543804 controller.loadViewIfNeeded()
packages/ios/src/index.test.tsmodified+22-1
Changes to packages/ios/src/index.test.ts
@@ -254,6 +254,10 @@ describe('Craft iOS builder', () => {
254254 expect(nativeSwift).toContain('UIApplication.didBecomeActiveNotification')
255255 expect(nativeSwift).toContain('DeepLinkManager.shared.addNativeListener')
256256 expect(nativeSwift).toContain('navigationController?.topViewController === self')
257 expect(nativeSwift).toContain('final class CraftNativeFrameClock: NSObject')
258 expect(nativeSwift).toContain('let link = CADisplayLink(target: self, selector: #selector(frame(_:)))')
259 expect(nativeSwift).toContain('link.add(to: .main, forMode: .common)')
260 expect(nativeSwift).toContain('if waiting.isEmpty { link.isPaused = true }')
257261 expect(nativeSwift).toContain('private class CraftNativeFlowView: UIStackView')
258262 expect(nativeSwift).toContain('let frameCount = output.count')
259263 expect(nativeSwift).toContain('private func sharedTreeFrames(')
@@ -544,6 +548,8 @@ describe('Craft iOS builder', () => {
544548 expect(native).toContain('pendingCapabilityDeadlines.values.forEach { $0.cancel() }')
545549 expect(native).toContain('CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }')
546550 expect(native).toContain('pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }')
551 expect(native).toContain('self?.runtimeDisposed != false ? false : ok')
552 expect(native).toContain('never as a successful write into a dead runtime')
547553 expect(native).toContain('lifecycleObservers.forEach(NotificationCenter.default.removeObserver)')
548554 expect(native).not.toContain('WKWebView')
549555 })
@@ -1760,7 +1766,7 @@ describe('Craft iOS builder', () => {
17601766 // A tour pager labelled for VoiceOver became one element and hid its pages.
17611767 const swift = readFileSync(join(import.meta.dir, '..', 'templates', 'CraftNativeScreen.swift'), 'utf8')
17621768 const a11y = swift.slice(swift.indexOf('private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView)'))
1763 expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)')
1769 expect(a11y).toContain('let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)')
17641770 expect(a11y).toContain('view.isAccessibilityElement = !container && role != "none"')
17651771 expect(a11y).toContain('view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none')
17661772 expect(a11y).toContain('case "tablist": traits.insert(.tabBar)')
@@ -2137,12 +2143,27 @@ describe('Craft iOS builder', () => {
21372143 expect(audio).toContain('CraftNativeSounds.releaseSession()')
21382144 expect(services).toContain('category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback')
21392145 expect(services).toContain('try session.setCategory(category, mode: .default, options: [.mixWithOthers])')
2146 expect(services).toContain('AVAudioSession.interruptionNotification')
2147 expect(services).toContain('sound.interrupted = true')
2148 expect(services).toContain('AVAudioSession.InterruptionOptions.shouldResume')
2149 expect(screen).toContain('reply(.success(sounds.pause(id: id)))')
2150 expect(screen).toContain('reply(.success(sounds.resume(id: id)))')
2151 expect(screen).toContain('reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))')
21402152 expect(plist).not.toContain('<string>audio</string>')
21412153 // A route change or an interruption stops AVAudioEngine; it starts again.
21422154 expect(audio).toContain('forName: .AVAudioEngineConfigurationChange, object: engine')
21432155 expect(audio).toContain('AVAudioSession.interruptionNotification')
21442156 })
21452157
2158 it('cleans failed native photo writes instead of leaving staging folders behind', async () => {
2159 const output = createTempDir('craft-ios-native-file-cleanup-')
2160 await init({ runtimeDir: null, name: 'Gate', bundleId: 'com.example.gate', output, config: { nativeScreens: { '/m/import': 'Import' } } as any })
2161 const files = readFileSync(join(output, 'Sources', 'CraftNativeFiles.swift'), 'utf8')
2162 expect(files).toContain('CGImageDestinationCreateWithURL')
2163 expect(files).toContain('CGImageDestinationFinalize(destination) else {')
2164 expect(files.match(/removeItem\(at: url\.deletingLastPathComponent\(\)\)/g)?.length).toBe(3)
2165 })
2166
21462167 it('runs an all-native app with the shell\'s tabs and paths, an app script and the web view\'s services', async () => {
21472168 const output = createTempDir('craft-ios-native-app-')
21482169 const bundle = join(output, 'bundle.js')
packages/ios/templates/CraftNativeFiles.swiftmodified+2-0
Changes to packages/ios/templates/CraftNativeFiles.swift
@@ -192,10 +192,12 @@ enum CraftNativeFiles {
192192 do {
193193 let url = try newFile(named: name)
194194 guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
195 try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
195196 return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
196197 }
197198 CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
198199 guard CGImageDestinationFinalize(destination) else {
200 try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
199201 return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
200202 }
201203 return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
packages/ios/templates/CraftNativeScreen.swiftmodified+49-15
Changes to packages/ios/templates/CraftNativeScreen.swift
@@ -4437,6 +4437,36 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
44374437 }
44384438}
44394439
4440/// A pressable wrapper must yield to an actionable descendant. Without this
4441/// ordering UIKit may let the ancestor's tap recognizer win the same touch,
4442/// so a nested Button never receives its physical action. A descendant
4443/// control or tap recognizer is the source of the event; STX handles the
4444/// logical bubbling from there.
4445final class CraftPressGestureDelegate: NSObject, UIGestureRecognizerDelegate {
4446 static let shared = CraftPressGestureDelegate()
4447
4448 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRequireFailureOf otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4449 guard let parent = gestureRecognizer.view,
4450 let child = otherGestureRecognizer.view,
4451 child !== parent else { return false }
4452 return child.isDescendant(of: parent)
4453 }
4454
4455 func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
4456 guard let container = gestureRecognizer.view else { return true }
4457 let hitView = container.hitTest(touch.location(in: container), with: nil)
4458 guard var candidate = hitView ?? touch.view else { return true }
4459 while candidate !== container {
4460 if candidate is UIControl { return false }
4461 if candidate.gestureRecognizers?.contains(where: { $0 !== gestureRecognizer && $0 is UITapGestureRecognizer }) == true {
4462 return false
4463 }
4464 candidate = candidate.superview ?? container
4465 }
4466 return true
4467 }
4468}
4469
44404470/// The delegate of a Pressable's press-in/out feedback. That recognizer is
44414471/// a long press of no duration, so it begins the moment a finger lands; on its
44424472/// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -6633,14 +6663,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
66336663 CraftSnapshots.read(name)
66346664 }
66356665 let snapshotSet: @convention(block) (String, JSValue, JSValue) -> Void = { name, value, done in
6636 CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { ok in
6637 CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6666 CraftSnapshots.write(name, json: value.isNull || value.isUndefined ? nil : value.toString()) { [weak self] ok in
6667 CraftNativeScriptThread.run {
6668 // A writer can finish after UIKit has disposed this
6669 // screen. Resolve the pending promise as a cancellation,
6670 // never as a successful write into a dead runtime.
6671 done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6672 }
66386673 }
66396674 }
66406675 let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
66416676 let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
6642 CraftSnapshots.clear(keeping: Set(names)) { ok in
6643 CraftNativeScriptThread.run { done.call(withArguments: [ok]) }
6677 CraftSnapshots.clear(keeping: Set(names)) { [weak self] ok in
6678 CraftNativeScriptThread.run {
6679 done.call(withArguments: [self?.runtimeDisposed != false ? false : ok])
6680 }
66446681 }
66456682 }
66466683 let config = self.config
@@ -8009,20 +8046,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
80098046 screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) }
80108047 sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply)
80118048 case ("Audio", "pause"):
8012 sounds.pause(id: id)
8013 reply(.success(true))
8049 reply(.success(sounds.pause(id: id)))
80148050 case ("Audio", "resume"):
8015 sounds.resume(id: id)
8016 reply(.success(true))
8051 reply(.success(sounds.resume(id: id)))
80178052 case ("Audio", "setVolume"):
8018 sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)
8019 reply(.success(true))
8053 reply(.success(sounds.setVolume(id: id, volume: number(1), fade: number(2, "fade") / 1000)))
80208054 case ("Audio", "stop"):
8021 sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)
8022 reply(.success(true))
8055 reply(.success(sounds.stop(id: id, fadeOut: number(1, "fadeOut") / 1000)))
80238056 case ("Audio", "stopAll"):
8024 sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)
8025 reply(.success(true))
8057 reply(.success(sounds.stopAll(fadeOut: number(0, "fadeOut") / 1000)))
80268058 default:
80278059 return false
80288060 }
@@ -9433,6 +9465,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
94339465 let actionable = handler != nil || linkTargets[id] != nil
94349466 if actionable, tapRecognizers[id] == nil {
94359467 let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
9468 recognizer.cancelsTouchesInView = false
9469 recognizer.delegate = CraftPressGestureDelegate.shared
94369470 tapRecognizers[id] = recognizer
94379471 view.addGestureRecognizer(recognizer)
94389472 } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) {
@@ -10353,7 +10387,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1035310387 let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
1035410388 // UIPickerView exposes adjustable component wheels. Making the
1035510389 // picker itself a leaf hides those wheels and their selected values.
10356 let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl"].contains(type)
10390 let container = ["ScrollView", "FlatList", "SectionList", "Picker", "SegmentedControl", "Modal", "Drawer"].contains(type)
1035710391 || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
1035810392 || controlledVideo
1035910393 view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
packages/ios/templates/CraftNativeServices.swiftmodified+58-10
Changes to packages/ios/templates/CraftNativeServices.swift
@@ -279,6 +279,7 @@ final class CraftNativeSounds {
279279 /// The volume asked for; a fade in or out moves toward or away from it.
280280 var volume: Float
281281 var paused = false
282 var interrupted = false
282283
283284 init(player: AVQueuePlayer, volume: Float) {
284285 self.player = player
@@ -306,9 +307,44 @@ final class CraftNativeSounds {
306307 private static var category: AVAudioSession.Category = .ambient
307308
308309 private var sounds: [String: Sound] = [:]
310 private var interruptionObserver: NSObjectProtocol?
309311 /// `{ id, event: "ended" | "error", message? }` to the screen's script.
310312 var emit: (([String: Any]) -> Void)?
311313
314 init() {
315 interruptionObserver = NotificationCenter.default.addObserver(
316 forName: AVAudioSession.interruptionNotification,
317 object: nil,
318 queue: .main,
319 ) { [weak self] note in
320 guard let self else { return }
321 let type = (note.userInfo?[AVAudioSessionInterruptionTypeKey] as? NSNumber)?.uintValue
322 if type == AVAudioSession.InterruptionType.began.rawValue {
323 self.sounds.values.forEach { sound in
324 guard !sound.paused else { return }
325 sound.interrupted = true
326 sound.player.pause()
327 }
328 return
329 }
330 guard type == AVAudioSession.InterruptionType.ended.rawValue else { return }
331 let options = (note.userInfo?[AVAudioSessionInterruptionOptionKey] as? NSNumber)?.uintValue ?? 0
332 let shouldResume = options & AVAudioSession.InterruptionOptions.shouldResume.rawValue != 0
333 self.sounds.values.forEach { sound in
334 guard sound.interrupted else { return }
335 sound.interrupted = false
336 // Without permission to auto-resume, keep a resumable paused
337 // handle. A later unrelated interruption must not restart it.
338 sound.paused = !shouldResume
339 if shouldResume { sound.player.play() }
340 }
341 }
342 }
343
344 deinit {
345 if let interruptionObserver { NotificationCenter.default.removeObserver(interruptionObserver) }
346 }
347
312348 /// Starts `url`. `answer` hears once it plays (or could not be loaded).
313349 func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
314350 stop(id: id, fadeOut: 0)
@@ -362,40 +398,52 @@ final class CraftNativeSounds {
362398 if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) }
363399 }
364400
365 func pause(id: String) {
366 guard let sound = sounds[id] else { return }
401 @discardableResult
402 func pause(id: String) -> Bool {
403 guard let sound = sounds[id] else { return false }
367404 sound.paused = true
405 sound.interrupted = false
368406 sound.fade?.invalidate()
369407 sound.player.pause()
408 return true
370409 }
371410
372 func resume(id: String) {
373 guard let sound = sounds[id], sound.paused else { return }
411 @discardableResult
412 func resume(id: String) -> Bool {
413 guard let sound = sounds[id], sound.paused else { return false }
374414 sound.paused = false
415 sound.interrupted = false
375416 sound.player.volume = sound.volume
376417 sound.player.play()
418 return true
377419 }
378420
379421 /// Changes the volume, over `fade` seconds when given.
380 func setVolume(id: String, volume: Double, fade: Double) {
381 guard let sound = sounds[id] else { return }
422 @discardableResult
423 func setVolume(id: String, volume: Double, fade: Double) -> Bool {
424 guard let sound = sounds[id] else { return false }
382425 sound.volume = Float(min(max(volume, 0), 1))
383426 if fade > 0, !sound.paused { ramp(sound, to: sound.volume, over: fade) }
384427 else { sound.fade?.invalidate(); sound.player.volume = sound.volume }
428 return true
385429 }
386430
387431 /// Stops the sound, fading it out over `fadeOut` seconds first.
388 func stop(id: String, fadeOut: Double) {
389 guard let sound = sounds[id] else { return }
390 guard fadeOut > 0, !sound.paused else { remove(id); return }
432 @discardableResult
433 func stop(id: String, fadeOut: Double) -> Bool {
434 guard let sound = sounds[id] else { return false }
435 guard fadeOut > 0, !sound.paused else { remove(id); return true }
391436 ramp(sound, to: 0, over: fadeOut) { [weak self, weak sound] in
392437 guard let self, let sound, self.sounds[id] === sound else { return }
393438 self.remove(id)
394439 }
440 return true
395441 }
396442
397 func stopAll(fadeOut: Double) {
443 @discardableResult
444 func stopAll(fadeOut: Double) -> Bool {
398445 for id in Array(sounds.keys) { stop(id: id, fadeOut: fadeOut) }
446 return true
399447 }
400448
401449 /// The screen closed: everything stops at once.
scripts/native-renderer-contract.test.tsmodified+1-1
Changes to scripts/native-renderer-contract.test.ts
@@ -4163,7 +4163,7 @@ describe('native renderer component contract', () => {
41634163 expect(androidCapabilities).toContain('CRAFT_NATIVE_FILE_ERROR_BODY_BYTES = 64 * 1024')
41644164 expect(androidCapabilities).toContain('private fun sweepFiles()')
41654165 expect(androidCapabilities).not.toContain('fileRoot.deleteRecursively()')
4166 expect(androidCapabilities).toContain('private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject?')
4166 expect(androidCapabilities).toContain('private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String, requestToken: String? = null): JSONObject?')
41674167 expect(androidCapabilities).toContain('CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048')
41684168 expect(android).toContain('capabilities.onActivityResult(requestCode, resultCode, data)')
41694169 })