also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
19 files
+987
-50
| @@ -1391,6 +1391,40 @@ unchanged. `craft.files.download` resolves a non-2xx response as | ||
| 1391 | 1391 | `{ ok: false, status, body }`; transport failures still reject with the native |
| 1392 | 1392 | network error codes. |
| 1393 | 1393 | |
| 1394 | Both maintained native hosts implement `craft.files.pickFile`. Android maps the | |
| 1395 | shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies | |
| 1396 | each selected document into the same app-owned directory, and enforces the | |
| 1397 | shared 512 MiB default/per-file limit before publishing a descriptor. A | |
| 1398 | dismissed picker resolves with an empty array; a canceled or oversized copy | |
| 1399 | never leaves a partial file behind. The same cancellation cleanup applies while | |
| 1400 | Android is decoding a picked image or copying a video, so a request canceled | |
| 1401 | after the picker closes cannot publish or leak an app-owned file. Use this for | |
| 1402 | GPX, TCX, FIT, PDF, or other documents that are not photos or videos. On iOS, | |
| 1403 | failed photo JPEG creation or finalization also removes its staging directory, | |
| 1404 | so a rejected image import does not accumulate orphaned temporary files. | |
| 1405 | ||
| 1406 | `craft.audio` playback is screen-owned on both hosts. Android requests audio | |
| 1407 | focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`, | |
| 1408 | pauses on a transient interruption and resumes on focus gain, ducks during a | |
| 1409 | duckable interruption, and releases focus when the last sound ends or the | |
| 1410 | screen closes. iOS observes `AVAudioSession` interruptions and resumes sounds | |
| 1411 | only when the system grants the `shouldResume` option. A permanent Android | |
| 1412 | focus loss releases every player and sends each sound's error event; replacement, | |
| 1413 | decode failure, cancellation, interruption, and teardown also release their | |
| 1414 | player resources. | |
| 1415 | Control calls resolve `false` when their sound id is not owned by the screen, | |
| 1416 | matching the browser host instead of reporting a false success. | |
| 1417 | ||
| 1418 | Checkpoint names use the same bounded filename contract on both native hosts: | |
| 1419 | letters, digits, `.`, `_`, and `-`, up to 128 characters, without a leading | |
| 1420 | dot. Non-JSON values and invalid names are rejected before a write is posted; | |
| 1421 | the previous confirmed checkpoint remains available after a failed or | |
| 1422 | interrupted replacement. If a screen closes while a checkpoint is still being | |
| 1423 | written, its promise rejects instead of hanging; the host's later worker | |
| 1424 | completion cannot publish a second result for that request. iOS also reports a | |
| 1425 | failed result when its writer completes after the screen runtime is disposed or | |
| 1426 | has already been deallocated. | |
| 1427 | ||
| 1394 | 1428 | The `media` capability (iOS) keeps video and audio on the phone for offline, |
| 1395 | 1429 | in Application Support rather than the temporary directory, left out of |
| 1396 | 1430 | iCloud backups. `craft.media.keep(url, { headers })` downloads the file once |
| @@ -201,6 +201,10 @@ describe('Craft Android builder', () => { | ||
| 201 | 201 | expect(refreshableFlatList).toContain('isRefreshing = false') |
| 202 | 202 | expect(flatList).toContain('fun setKeyboardDismissMode(mode: String, dismissKeyboard: () -> Unit)') |
| 203 | 203 | 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') | |
| 204 | 208 | expect(flatList).toContain('renderedLayout?.height ?: LayoutParams.WRAP_CONTENT') |
| 205 | 209 | expect(nativeActivity).toContain('val nestedLists = refreshableLists.map { it.list }.toSet()') |
| 206 | 210 | expect(nativeActivity).toContain('released.addAll(nestedLists)') |
| @@ -716,6 +720,9 @@ describe('Craft Android builder', () => { | ||
| 716 | 720 | expect(native).toContain('showError("Missing native-screen.js. Build with --native-bundle after compiling STX.")') |
| 717 | 721 | expect(native).toContain('screen.capabilityTimeouts.values.forEach(capabilityHandler::removeCallbacks)') |
| 718 | 722 | 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)') | |
| 719 | 726 | expect(native).toContain('screen.close {') |
| 720 | 727 | expect(native).toContain('imageJobs.values.forEach { it.cancel(true) }') |
| 721 | 728 | expect(native).toContain('imageExecutor.shutdownNow()') |
| @@ -785,6 +792,49 @@ describe('Craft Android builder', () => { | ||
| 785 | 792 | expect(native).toContain('control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }') |
| 786 | 793 | }) |
| 787 | 794 | |
| 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 | ||
| 788 | 838 | it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => { |
| 789 | 839 | const output = createTempDir('craft-android-native-wallet-') |
| 790 | 840 | await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } }) |
| @@ -900,6 +900,7 @@ import com.google.android.gms.pay.PayClient` : '') | ||
| 900 | 900 | done(failure("PUSH_UNAVAILABLE", error.message ?: "Firebase Cloud Messaging is not configured")) |
| 901 | 901 | }` |
| 902 | 902 | : ' done(failure("CAPABILITY_DISABLED", "Push notifications are disabled"))')) |
| 903 | write('CraftNativeSounds.kt.template', 'CraftNativeSounds.kt') | |
| 903 | 904 | if (config.enablePushNotifications) write('CraftNativeMessagingService.kt.template', 'CraftNativeMessagingService.kt') |
| 904 | 905 | else rmSync(join(javaDir, 'CraftNativeMessagingService.kt'), { force: true }) |
| 905 | 906 | // Files a screen shares (craft.files' copies, in the cache) go to other apps through this provider. |
| @@ -100,6 +100,14 @@ describe('the native shell prelude (JavaScriptSandbox has no synchronous host ca | ||
| 100 | 100 | expect(app.context.craft.snapshots.get('campaign')).toEqual({ tick: 24 }) |
| 101 | 101 | }) |
| 102 | 102 | |
| 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 | ||
| 103 | 111 | it('follows another isolate\'s writes and tells the screen a snapshot changed', () => { |
| 104 | 112 | const app = isolate({ snapshots: { header: '{"userId":1}' } }) |
| 105 | 113 | app.deliver({ type: 'SYNC_UPDATE', payload: { store: 'snapshots', key: 'header', value: '{"userId":2}' } }) |
| @@ -112,6 +112,8 @@ internal class CraftNativeCapabilities( | ||
| 112 | 112 | val quality: Float, |
| 113 | 113 | val completion: (CraftNativeCapabilityResult) -> Unit, |
| 114 | 114 | val videoLimit: Long = 0, |
| 115 | val selectionLimit: Int = 1, | |
| 116 | val fileLimit: Long = 0, | |
| 115 | 117 | ) |
| 116 | 118 | |
| 117 | 119 | private val connectivity = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager |
| @@ -218,6 +220,7 @@ internal class CraftNativeCapabilities( | ||
| 218 | 220 | "Files" -> when (method) { |
| 219 | 221 | "pickImage" -> pickImage(requestToken, args, completion) |
| 220 | 222 | "pickVideo" -> pickVideo(requestToken, args, completion) |
| 223 | "pickFile" -> pickFile(requestToken, args, completion) | |
| 221 | 224 | "download", "write", "remove" -> networkExecutor.execute { |
| 222 | 225 | complete(requestToken, files(requestToken, method, args), completion) |
| 223 | 226 | } |
| @@ -512,9 +515,10 @@ internal class CraftNativeCapabilities( | ||
| 512 | 515 | } |
| 513 | 516 | |
| 514 | 517 | /** Copy with a bound even when a cloud provider does not report its size. */ |
| 515 | private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult { | |
| 518 | private fun copyVideo(requestToken: String, uri: Uri, limit: Long): CraftNativeCapabilityResult { | |
| 516 | 519 | var target: File? = null |
| 517 | 520 | return try { |
| 521 | if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled") | |
| 518 | 522 | val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { |
| 519 | 523 | if (it.moveToFirst()) it.getString(0) else null |
| 520 | 524 | } ?: "video.mp4" |
| @@ -531,6 +535,7 @@ internal class CraftNativeCapabilities( | ||
| 531 | 535 | while (true) { |
| 532 | 536 | val count = source.read(buffer) |
| 533 | 537 | if (count < 0) break |
| 538 | if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled") | |
| 534 | 539 | size += count |
| 535 | 540 | require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." } |
| 536 | 541 | output.write(buffer, 0, count) |
| @@ -541,7 +546,9 @@ internal class CraftNativeCapabilities( | ||
| 541 | 546 | success(descriptor(destination, type)) |
| 542 | 547 | } catch (error: Exception) { |
| 543 | 548 | target?.parentFile?.deleteRecursively() |
| 544 | 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) | |
| 545 | 552 | } |
| 546 | 553 | } |
| 547 | 554 | |
| @@ -585,6 +592,76 @@ internal class CraftNativeCapabilities( | ||
| 585 | 592 | } |
| 586 | 593 | } |
| 587 | 594 | |
| 595 | private fun pickFile(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) { | |
| 596 | mainHandler.post { | |
| 597 | val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion) | |
| 598 | if (pendingFilePick != null) { | |
| 599 | complete(requestToken, failure("BUSY", "Another file picker is already open"), completion) | |
| 600 | return@post | |
| 601 | } | |
| 602 | val options = args.optJSONObject(0) ?: JSONObject() | |
| 603 | val types = documentTypes(options.optJSONArray("contentTypes")) | |
| 604 | if (types == null) { | |
| 605 | complete(requestToken, failure("INVALID_ARGUMENT", "files.pickFile contentTypes must be MIME types, UTIs, or extensions"), completion) | |
| 606 | return@post | |
| 607 | } | |
| 608 | val selections = options.optInt("maxSelections", 1).coerceIn(1, 20) | |
| 609 | val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024) | |
| 610 | pendingFilePick = PendingFilePick( | |
| 611 | requestToken, | |
| 612 | "file", | |
| 613 | 0, | |
| 614 | 1f, | |
| 615 | completion, | |
| 616 | selectionLimit = selections, | |
| 617 | fileLimit = limit, | |
| 618 | ) | |
| 619 | val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { | |
| 620 | addCategory(Intent.CATEGORY_OPENABLE) | |
| 621 | type = types.first | |
| 622 | if (types.second.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, types.second) | |
| 623 | putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selections > 1) | |
| 624 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) | |
| 625 | } | |
| 626 | try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) } | |
| 627 | catch (error: Exception) { | |
| 628 | pendingFilePick = null | |
| 629 | complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "File picker could not be opened"), completion) | |
| 630 | } | |
| 631 | } | |
| 632 | } | |
| 633 | ||
| 634 | /** Android's picker accepts MIME filters; map the common UTI/extension forms used by the shared API. */ | |
| 635 | private fun documentTypes(values: JSONArray?): Pair<String, Array<String>>? { | |
| 636 | if (values == null || values.length() == 0) return "*/*" to emptyArray() | |
| 637 | val mime = mutableListOf<String>() | |
| 638 | for (index in 0 until values.length()) { | |
| 639 | val raw = values.optString(index).trim().lowercase() | |
| 640 | if (raw.isBlank()) return null | |
| 641 | val type = when (raw) { | |
| 642 | "public.data", "public.item", "public.content" -> "*/*" | |
| 643 | "public.image" -> "image/*" | |
| 644 | "public.movie" -> "video/*" | |
| 645 | "public.audio" -> "audio/*" | |
| 646 | "public.text" -> "text/*" | |
| 647 | else -> when { | |
| 648 | raw.contains('/') -> raw | |
| 649 | raw == "gpx" -> "application/gpx+xml" | |
| 650 | raw == "tcx" -> "application/vnd.garmin.tcx+xml" | |
| 651 | raw == "fit" -> "application/octet-stream" | |
| 652 | raw == "json" -> "application/json" | |
| 653 | raw == "csv" -> "text/csv" | |
| 654 | raw == "txt" -> "text/plain" | |
| 655 | raw == "pdf" -> "application/pdf" | |
| 656 | else -> return null | |
| 657 | } | |
| 658 | } | |
| 659 | mime += type | |
| 660 | } | |
| 661 | val distinct = mime.distinct() | |
| 662 | return if (distinct.size == 1) distinct.first() to emptyArray() else "*/*" to distinct.toTypedArray() | |
| 663 | } | |
| 664 | ||
| 588 | 665 | fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { |
| 589 | 666 | if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false |
| 590 | 667 | val pending = pendingFilePick ?: return true |
| @@ -595,24 +672,45 @@ internal class CraftNativeCapabilities( | ||
| 595 | 672 | } |
| 596 | 673 | if (pending.source == "video") { |
| 597 | 674 | networkExecutor.execute { |
| 598 | val result = data?.data?.let { copyVideo(it, pending.videoLimit) } | |
| 675 | val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) } | |
| 599 | 676 | ?: failure("PICKER_FAILED", "The selected video could not be read") |
| 600 | 677 | complete(pending.requestToken, result, pending.completion) |
| 601 | 678 | } |
| 602 | 679 | return true |
| 603 | 680 | } |
| 604 | 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 | ) | |
| 610 | 708 | } |
| 611 | complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion) | |
| 612 | 709 | return true |
| 613 | 710 | } |
| 614 | 711 | |
| 615 | private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? { | |
| 712 | private fun copyUri(requestToken: String, uri: Uri, maxPixels: Int, quality: Float): JSONObject? { | |
| 713 | if (cancelled.contains(requestToken)) return null | |
| 616 | 714 | val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { |
| 617 | 715 | if (it.moveToFirst()) it.getString(0) else null |
| 618 | 716 | } ?: "photo" |
| @@ -623,10 +721,74 @@ internal class CraftNativeCapabilities( | ||
| 623 | 721 | while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2 |
| 624 | 722 | val options = BitmapFactory.Options().apply { inSampleSize = sample } |
| 625 | 723 | val bitmap = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null |
| 626 | return writeImage(bitmap, maxPixels, quality, jpegName(name)) | |
| 724 | return writeImage(bitmap, maxPixels, quality, jpegName(name), requestToken) | |
| 627 | 725 | } |
| 628 | 726 | |
| 629 | private fun 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 | } | |
| 630 | 792 | val longest = max(bitmap.width, bitmap.height) |
| 631 | 793 | val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f |
| 632 | 794 | val output = if (scale < 1f) Bitmap.createScaledBitmap( |
| @@ -643,7 +805,15 @@ internal class CraftNativeCapabilities( | ||
| 643 | 805 | }.getOrDefault(false) |
| 644 | 806 | if (output !== bitmap) output.recycle() |
| 645 | 807 | bitmap.recycle() |
| 646 | 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) | |
| 647 | 817 | } |
| 648 | 818 | |
| 649 | 819 | private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult { |
| @@ -373,6 +373,10 @@ internal class CraftNativeStores private constructor( | ||
| 373 | 373 | |
| 374 | 374 | /** `craft.snapshots.set`: null deletes it. `done` hears whether it was kept, on the main thread. */ |
| 375 | 375 | fun setSnapshot(name: String, json: String?, from: String?, done: (Boolean) -> Unit) { |
| 376 | if (!isSnapshotName(name)) { | |
| 377 | main.post { done(false) } | |
| 378 | return | |
| 379 | } | |
| 376 | 380 | writer.execute { |
| 377 | 381 | val file = snapshotFile(name) |
| 378 | 382 | val previous = snapshotCache[name] |
| @@ -422,6 +426,8 @@ internal class CraftNativeStores private constructor( | ||
| 422 | 426 | CraftNativeHub.broadcast("SYNC_UPDATE", payload, except = from) |
| 423 | 427 | } |
| 424 | 428 | |
| 429 | private fun isSnapshotName(name: String): Boolean = name.matches(Regex("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}")) | |
| 430 | ||
| 425 | 431 | private fun snapshotFile(name: String): File = File(snapshotsDir, URLEncoder.encode(name, "UTF-8") + ".json") |
| 426 | 432 | |
| 427 | 433 | private fun snapshotName(file: File): String? = |
| @@ -659,6 +665,9 @@ internal object CraftNativePrelude { | ||
| 659 | 665 | if (json === null || json === undefined) return null; |
| 660 | 666 | try { return JSON.parse(json); } catch (error) { return null; } |
| 661 | 667 | } |
| 668 | function validSnapshotName(name) { | |
| 669 | return /^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,127}$/.test(name); | |
| 670 | } | |
| 662 | 671 | var craft = globalThis.craft = globalThis.craft || {}; |
| 663 | 672 | function keep(name, extras) { |
| 664 | 673 | var current = {}; |
| @@ -681,12 +690,18 @@ internal object CraftNativePrelude { | ||
| 681 | 690 | } |
| 682 | 691 | }); |
| 683 | 692 | keep('snapshots', { |
| 684 | get: function(name) { return parsed(sync.snapshots[String(name)]); }, | |
| 693 | get: function(name) { | |
| 694 | name = String(name); | |
| 695 | return validSnapshotName(name) ? parsed(sync.snapshots[name]) : null; | |
| 696 | }, | |
| 685 | 697 | set: function(name, value) { |
| 686 | 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 }); | |
| 690 | 705 | }, |
| 691 | 706 | clear: function(keepNames) { |
| 692 | 707 | var names = Array.isArray(keepNames) ? keepNames.map(String) : []; |
| @@ -0,0 +1,260 @@ | ||
| 1 | package | |
| 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 | } | |
| @@ -4250,6 +4250,10 @@ class MainActivity : AppCompatActivity() { | ||
| 4250 | 4250 | val pendingCapabilities = mutableSetOf<String>() |
| 4251 | 4251 | val capabilityTimeouts = mutableMapOf<String, Runnable>() |
| 4252 | 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>() | |
| 4253 | 4257 | var active = true |
| 4254 | 4258 | var closing = false |
| 4255 | 4259 | private set |
| @@ -4265,6 +4269,9 @@ class MainActivity : AppCompatActivity() { | ||
| 4265 | 4269 | active = false |
| 4266 | 4270 | closing = true |
| 4267 | 4271 | audio.close() |
| 4272 | soundRequestCancellers.values.toList().forEach { it() } | |
| 4273 | soundRequestCancellers.clear() | |
| 4274 | sounds.close() | |
| 4268 | 4275 | // Evaluation completion does not mean its outbound messages have |
| 4269 | 4276 | // reached the host. Close only after the ordered channel barrier, |
| 4270 | 4277 | // so onDestroy's final writes run before navigation continues. |
| @@ -4796,6 +4803,9 @@ class MainActivity : AppCompatActivity() { | ||
| 4796 | 4803 | activeScreen.view.onLayoutChanged = { |
| 4797 | 4804 | if (activeScreen.active) emitLayoutEvents(activeScreen) |
| 4798 | 4805 | } |
| 4806 | activeScreen.sounds.emit = { payload -> | |
| 4807 | if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload) | |
| 4808 | } | |
| 4799 | 4809 | screen = activeScreen |
| 4800 | 4810 | CraftNativeHub.register(activeScreen.capabilityScope) { type, payload -> |
| 4801 | 4811 | if (activeScreen.active) send(activeScreen, type, payload) |
| @@ -5173,6 +5183,7 @@ class MainActivity : AppCompatActivity() { | ||
| 5173 | 5183 | /** The bridge's capability names: Craft's core set and this host's services. */ |
| 5174 | 5184 | private fun capabilityNames(): JSONArray = capabilities.names().apply { |
| 5175 | 5185 | services.names().forEach { name -> put(name) } |
| 5186 | put("audio") | |
| 5176 | 5187 | put("viewFocus") |
| 5177 | 5188 | put("viewMeasure") |
| 5178 | 5189 | put("viewScroll") |
| @@ -5191,13 +5202,20 @@ class MainActivity : AppCompatActivity() { | ||
| 5191 | 5202 | /** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */ |
| 5192 | 5203 | private fun syncWrite(screen: Screen, payload: JSONObject) { |
| 5193 | 5204 | val id = payload.optString("id").takeIf { it.isNotBlank() } |
| 5205 | val store = payload.optString("store") | |
| 5206 | if (id != null) screen.pendingSyncResults.add(id) | |
| 5194 | 5207 | 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 | } | |
| 5196 | 5211 | } |
| 5197 | 5212 | val key = payload.optString("key") |
| 5198 | 5213 | 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) | |
| 5201 | 5219 | "snapshots" -> if (payload.optBoolean("clear")) { |
| 5202 | 5220 | val keep = payload.optJSONArray("keep") ?: JSONArray() |
| 5203 | 5221 | stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply) |
| @@ -5438,6 +5456,7 @@ class MainActivity : AppCompatActivity() { | ||
| 5438 | 5456 | val token = "${screen.capabilityScope}/$id" |
| 5439 | 5457 | val pending = screen.pendingCapabilities.remove(token) |
| 5440 | 5458 | screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks) |
| 5459 | screen.soundRequestCancellers.remove(token)?.invoke() | |
| 5441 | 5460 | if (pending) capabilities.cancel(token) |
| 5442 | 5461 | } |
| 5443 | 5462 | } |
| @@ -7623,6 +7642,7 @@ class MainActivity : AppCompatActivity() { | ||
| 7623 | 7642 | val timeout = Runnable { |
| 7624 | 7643 | if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable |
| 7625 | 7644 | screen.capabilityTimeouts.remove(token) |
| 7645 | screen.soundRequestCancellers.remove(token)?.invoke() | |
| 7626 | 7646 | capabilities.cancel(token) |
| 7627 | 7647 | send(screen, "API_ERROR", JSONObject() |
| 7628 | 7648 | .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) |
| @@ -7640,6 +7660,14 @@ class MainActivity : AppCompatActivity() { | ||
| 7640 | 7660 | val method = payload.optString("method") |
| 7641 | 7661 | val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") { |
| 7642 | 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 | } | |
| 7643 | 7671 | } else if (services.handles(module)) { |
| 7644 | 7672 | { completion -> services.perform(module, method, args, completion) } |
| 7645 | 7673 | } else { |
| @@ -7657,6 +7685,7 @@ class MainActivity : AppCompatActivity() { | ||
| 7657 | 7685 | perform { result -> |
| 7658 | 7686 | if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform |
| 7659 | 7687 | screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks) |
| 7688 | screen.soundRequestCancellers.remove(token) | |
| 7660 | 7689 | val response = JSONObject() |
| 7661 | 7690 | .put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION) |
| 7662 | 7691 | .put("requestId", id) |
| @@ -7666,6 +7695,27 @@ class MainActivity : AppCompatActivity() { | ||
| 7666 | 7695 | } |
| 7667 | 7696 | } |
| 7668 | 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 | ||
| 7669 | 7719 | /** View operations are screen-owned: node ids only exist while a route is mounted. */ |
| 7670 | 7720 | private fun performView( |
| 7671 | 7721 | screen: Screen, |
| @@ -8280,6 +8330,13 @@ class MainActivity : AppCompatActivity() { | ||
| 8280 | 8330 | |
| 8281 | 8331 | private fun closeScreen(screen: Screen, done: () -> Unit = {}) { |
| 8282 | 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() | |
| 8283 | 8340 | CraftNativeHub.unregister(screen.capabilityScope) |
| 8284 | 8341 | screen.timers.values.forEach(capabilityHandler::removeCallbacks) |
| 8285 | 8342 | screen.timers.clear() |
| @@ -174,7 +174,7 @@ and linear gradients are applied by UIKit with the same style keys as Android; | ||
| 174 | 174 | `elevation` remains Android-specific. Gradient layers follow the native view's |
| 175 | 175 | bounds and corners through layout changes. |
| 176 | 176 | |
| 177 | The 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). | |
| 177 | The 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). | |
| 178 | 178 | |
| 179 | 179 | `FlatList` rows are authored as keyed `:for` child rows in native STX; the |
| 180 | 180 | maintained protocol does not serialize `data`/`renderItem` callbacks. |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import JavaScriptCore |
| 2 | import AVFoundation | |
| 2 | 3 | import XCTest |
| 3 | 4 | @testable import NativeRender |
| 4 | 5 | |
| @@ -7,6 +8,60 @@ import XCTest | ||
| 7 | 8 | /// samples the engine would play. |
| 8 | 9 | @MainActor |
| 9 | 10 | final 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 | ||
| 10 | 65 | private final class VoiceRecorder: CraftVoiceRecorder { |
| 11 | 66 | private let lock = NSLock() |
| 12 | 67 | private var records = 0 |
| @@ -3198,6 +3198,28 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 3198 | 3198 | XCTAssertTrue(disabled.accessibilityTraits.contains(.notEnabled)) |
| 3199 | 3199 | } |
| 3200 | 3200 | |
| 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 | ||
| 3201 | 3223 | func testModalCloseRequestAndDismissAreIndependentVisibilityEvents() throws { |
| 3202 | 3224 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 3203 | 3225 | controller.loadViewIfNeeded() |
| @@ -3749,6 +3771,34 @@ final class NativeRenderUnitTests: XCTestCase { | ||
| 3749 | 3771 | XCTAssertEqual(authored.accessibilityContainerType, .none) |
| 3750 | 3772 | } |
| 3751 | 3773 | |
| 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 | ||
| 3752 | 3802 | func testSwitchAccessibilityMatchesUIKitAcrossValueAndEnabledChanges() throws { |
| 3753 | 3803 | let controller = CraftNativeScreenController(config: CraftConfig()) |
| 3754 | 3804 | controller.loadViewIfNeeded() |
| @@ -254,6 +254,10 @@ describe('Craft iOS builder', () => { | ||
| 254 | 254 | expect(nativeSwift).toContain('UIApplication.didBecomeActiveNotification') |
| 255 | 255 | expect(nativeSwift).toContain('DeepLinkManager.shared.addNativeListener') |
| 256 | 256 | 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 }') | |
| 257 | 261 | expect(nativeSwift).toContain('private class CraftNativeFlowView: UIStackView') |
| 258 | 262 | expect(nativeSwift).toContain('let frameCount = output.count') |
| 259 | 263 | expect(nativeSwift).toContain('private func sharedTreeFrames(') |
| @@ -544,6 +548,8 @@ describe('Craft iOS builder', () => { | ||
| 544 | 548 | expect(native).toContain('pendingCapabilityDeadlines.values.forEach { $0.cancel() }') |
| 545 | 549 | expect(native).toContain('CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }') |
| 546 | 550 | 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') | |
| 547 | 553 | expect(native).toContain('lifecycleObservers.forEach(NotificationCenter.default.removeObserver)') |
| 548 | 554 | expect(native).not.toContain('WKWebView') |
| 549 | 555 | }) |
| @@ -1760,7 +1766,7 @@ describe('Craft iOS builder', () => { | ||
| 1760 | 1766 | // A tour pager labelled for VoiceOver became one element and hid its pages. |
| 1761 | 1767 | const swift = readFileSync(join(import.meta.dir, '..', 'templates', 'CraftNativeScreen.swift'), 'utf8') |
| 1762 | 1768 | 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)') | |
| 1764 | 1770 | expect(a11y).toContain('view.isAccessibilityElement = !container && role != "none"') |
| 1765 | 1771 | expect(a11y).toContain('view.accessibilityContainerType = container && (view.accessibilityLabel != nil || controlledVideo) ? .semanticGroup : .none') |
| 1766 | 1772 | expect(a11y).toContain('case "tablist": traits.insert(.tabBar)') |
| @@ -2137,12 +2143,27 @@ describe('Craft iOS builder', () => { | ||
| 2137 | 2143 | expect(audio).toContain('CraftNativeSounds.releaseSession()') |
| 2138 | 2144 | expect(services).toContain('category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback') |
| 2139 | 2145 | 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)))') | |
| 2140 | 2152 | expect(plist).not.toContain('<string>audio</string>') |
| 2141 | 2153 | // A route change or an interruption stops AVAudioEngine; it starts again. |
| 2142 | 2154 | expect(audio).toContain('forName: .AVAudioEngineConfigurationChange, object: engine') |
| 2143 | 2155 | expect(audio).toContain('AVAudioSession.interruptionNotification') |
| 2144 | 2156 | }) |
| 2145 | 2157 | |
| 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 | ||
| 2146 | 2167 | it('runs an all-native app with the shell\'s tabs and paths, an app script and the web view\'s services', async () => { |
| 2147 | 2168 | const output = createTempDir('craft-ios-native-app-') |
| 2148 | 2169 | const bundle = join(output, 'bundle.js') |
| @@ -192,10 +192,12 @@ enum CraftNativeFiles { | ||
| 192 | 192 | do { |
| 193 | 193 | let url = try newFile(named: name) |
| 194 | 194 | guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else { |
| 195 | try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) | |
| 195 | 196 | return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved")) |
| 196 | 197 | } |
| 197 | 198 | CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary) |
| 198 | 199 | guard CGImageDestinationFinalize(destination) else { |
| 200 | try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) | |
| 199 | 201 | return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved")) |
| 200 | 202 | } |
| 201 | 203 | return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height])) |
| @@ -4437,6 +4437,36 @@ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate { | ||
| 4437 | 4437 | } |
| 4438 | 4438 | } |
| 4439 | 4439 | |
| 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. | |
| 4445 | final 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 | ||
| 4440 | 4470 | /// The delegate of a Pressable's press-in/out feedback. That recognizer is |
| 4441 | 4471 | /// a long press of no duration, so it begins the moment a finger lands; on its |
| 4442 | 4472 | /// own it would claim the touch, and the tap that is the Pressable's onPress |
| @@ -6633,14 +6663,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 6633 | 6663 | CraftSnapshots.read(name) |
| 6634 | 6664 | } |
| 6635 | 6665 | 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 | } | |
| 6638 | 6673 | } |
| 6639 | 6674 | } |
| 6640 | 6675 | let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in |
| 6641 | 6676 | 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 | } | |
| 6644 | 6681 | } |
| 6645 | 6682 | } |
| 6646 | 6683 | let config = self.config |
| @@ -8009,20 +8046,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 8009 | 8046 | screenRequestCancellers[requestToken] = { [weak self] in self?.sounds.stop(id: id, fadeOut: 0) } |
| 8010 | 8047 | sounds.play(id: id, url: url, options: args.count > 2 ? args[2] as? [String: Any] ?? [:] : [:], answer: reply) |
| 8011 | 8048 | case ("Audio", "pause"): |
| 8012 | sounds.pause(id: id) | |
| 8013 | reply(.success(true)) | |
| 8049 | reply(.success(sounds.pause(id: id))) | |
| 8014 | 8050 | case ("Audio", "resume"): |
| 8015 | sounds.resume(id: id) | |
| 8016 | reply(.success(true)) | |
| 8051 | reply(.success(sounds.resume(id: id))) | |
| 8017 | 8052 | 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))) | |
| 8020 | 8054 | 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))) | |
| 8023 | 8056 | 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))) | |
| 8026 | 8058 | default: |
| 8027 | 8059 | return false |
| 8028 | 8060 | } |
| @@ -9433,6 +9465,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 9433 | 9465 | let actionable = handler != nil || linkTargets[id] != nil |
| 9434 | 9466 | if actionable, tapRecognizers[id] == nil { |
| 9435 | 9467 | let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:))) |
| 9468 | recognizer.cancelsTouchesInView = false | |
| 9469 | recognizer.delegate = CraftPressGestureDelegate.shared | |
| 9436 | 9470 | tapRecognizers[id] = recognizer |
| 9437 | 9471 | view.addGestureRecognizer(recognizer) |
| 9438 | 9472 | } else if !actionable, let recognizer = tapRecognizers.removeValue(forKey: id) { |
| @@ -10353,7 +10387,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, | ||
| 10353 | 10387 | let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true |
| 10354 | 10388 | // UIPickerView exposes adjustable component wheels. Making the |
| 10355 | 10389 | // 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) | |
| 10357 | 10391 | || ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "") |
| 10358 | 10392 | || controlledVideo |
| 10359 | 10393 | view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type)) |
| @@ -279,6 +279,7 @@ final class CraftNativeSounds { | ||
| 279 | 279 | /// The volume asked for; a fade in or out moves toward or away from it. |
| 280 | 280 | var volume: Float |
| 281 | 281 | var paused = false |
| 282 | var interrupted = false | |
| 282 | 283 | |
| 283 | 284 | init(player: AVQueuePlayer, volume: Float) { |
| 284 | 285 | self.player = player |
| @@ -306,9 +307,44 @@ final class CraftNativeSounds { | ||
| 306 | 307 | private static var category: AVAudioSession.Category = .ambient |
| 307 | 308 | |
| 308 | 309 | private var sounds: [String: Sound] = [:] |
| 310 | private var interruptionObserver: NSObjectProtocol? | |
| 309 | 311 | /// `{ id, event: "ended" | "error", message? }` to the screen's script. |
| 310 | 312 | var emit: (([String: Any]) -> Void)? |
| 311 | 313 | |
| 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 | ||
| 312 | 348 | /// Starts `url`. `answer` hears once it plays (or could not be loaded). |
| 313 | 349 | func play(id: String, url: URL, options: [String: Any], answer: @escaping (Result<Any, CraftNativeActionError>) -> Void) { |
| 314 | 350 | stop(id: id, fadeOut: 0) |
| @@ -362,40 +398,52 @@ final class CraftNativeSounds { | ||
| 362 | 398 | if fadeIn > 0 { ramp(sound, to: volume, over: fadeIn) } |
| 363 | 399 | } |
| 364 | 400 | |
| 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 } | |
| 367 | 404 | sound.paused = true |
| 405 | sound.interrupted = false | |
| 368 | 406 | sound.fade?.invalidate() |
| 369 | 407 | sound.player.pause() |
| 408 | return true | |
| 370 | 409 | } |
| 371 | 410 | |
| 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 } | |
| 374 | 414 | sound.paused = false |
| 415 | sound.interrupted = false | |
| 375 | 416 | sound.player.volume = sound.volume |
| 376 | 417 | sound.player.play() |
| 418 | return true | |
| 377 | 419 | } |
| 378 | 420 | |
| 379 | 421 | /// 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 } | |
| 382 | 425 | sound.volume = Float(min(max(volume, 0), 1)) |
| 383 | 426 | if fade > 0, !sound.paused { ramp(sound, to: sound.volume, over: fade) } |
| 384 | 427 | else { sound.fade?.invalidate(); sound.player.volume = sound.volume } |
| 428 | return true | |
| 385 | 429 | } |
| 386 | 430 | |
| 387 | 431 | /// 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 } | |
| 391 | 436 | ramp(sound, to: 0, over: fadeOut) { [weak self, weak sound] in |
| 392 | 437 | guard let self, let sound, self.sounds[id] === sound else { return } |
| 393 | 438 | self.remove(id) |
| 394 | 439 | } |
| 440 | return true | |
| 395 | 441 | } |
| 396 | 442 | |
| 397 | func stopAll(fadeOut: Double) { | |
| 443 | @discardableResult | |
| 444 | func stopAll(fadeOut: Double) -> Bool { | |
| 398 | 445 | for id in Array(sounds.keys) { stop(id: id, fadeOut: fadeOut) } |
| 446 | return true | |
| 399 | 447 | } |
| 400 | 448 | |
| 401 | 449 | /// The screen closed: everything stops at once. |
| @@ -4163,7 +4163,7 @@ describe('native renderer component contract', () => { | ||
| 4163 | 4163 | expect(androidCapabilities).toContain('CRAFT_NATIVE_FILE_ERROR_BODY_BYTES = 64 * 1024') |
| 4164 | 4164 | expect(androidCapabilities).toContain('private fun sweepFiles()') |
| 4165 | 4165 | 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?') | |
| 4167 | 4167 | expect(androidCapabilities).toContain('CRAFT_NATIVE_FILE_DEFAULT_MAX_PIXELS = 2048') |
| 4168 | 4168 | expect(android).toContain('capabilities.onActivityResult(requestCode, resultCode, data)') |
| 4169 | 4169 | }) |