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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/guides/native-stx.mdmodified+8-0
Changes to docs/guides/native-stx.md
@@ -1359,6 +1359,14 @@ unchanged. `craft.files.download` resolves a non-2xx response as
13591359`{ ok: false, status, body }`; transport failures still reject with the native
13601360network error codes.
13611361
1362Both maintained native hosts implement `craft.files.pickFile`. Android maps the
1363shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies
1364each selected document into the same app-owned directory, and enforces the
1365shared 512 MiB default/per-file limit before publishing a descriptor. A
1366dismissed picker resolves with an empty array; a canceled or oversized copy
1367never leaves a partial file behind. Use this for GPX, TCX, FIT, PDF, or other
1368documents that are not photos or videos.
1369
13621370The `media` capability (iOS) keeps video and audio on the phone for offline,
13631371in Application Support rather than the temporary directory, left out of
13641372iCloud backups. `craft.media.keep(url, { headers })` downloads the file once
packages/android/src/index.test.tsmodified+16-0
Changes to packages/android/src/index.test.ts
@@ -799,6 +799,22 @@ describe('Craft Android builder', () => {
799799 expect(native).toContain('"SOUND_EVENT"')
800800 })
801801
802 it('gives native screens an app-owned general document picker', async () => {
803 const output = createTempDir('craft-android-native-files-')
804 await init({ name: 'NativeFiles', packageName: 'org.example.nativefiles', output, runtimeDir: null, config: { renderer: 'native' } })
805 const root = join(output, 'app/src/main/java/org/example/nativefiles')
806 const capabilities = readFileSync(join(root, 'CraftNativeCapabilities.kt'), 'utf8')
807 expect(capabilities).toContain('"pickFile" -> pickFile(requestToken, args, completion)')
808 expect(capabilities).toContain('Intent.ACTION_OPEN_DOCUMENT')
809 expect(capabilities).toContain('Intent.EXTRA_ALLOW_MULTIPLE')
810 expect(capabilities).toContain('Intent.EXTRA_MIME_TYPES')
811 expect(capabilities).toContain('copyDocuments(pending.requestToken, uris, pending.fileLimit)')
812 expect(capabilities).toContain('FILE_TOO_LARGE')
813 expect(capabilities).toContain('CANCELLED: File selection was cancelled')
814 expect(capabilities).toContain('ownedFile(it)?.parentFile?.deleteRecursively()')
815 expect(capabilities).toContain('application/vnd.garmin.tcx+xml')
816 })
817
802818 it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => {
803819 const output = createTempDir('craft-android-native-wallet-')
804820 await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } })
packages/android/templates/CraftNativeCapabilities.kt.templatemodified+146-0
Changes to packages/android/templates/CraftNativeCapabilities.kt.template
@@ -107,6 +107,8 @@ internal class CraftNativeCapabilities(
107107 val quality: Float,
108108 val completion: (CraftNativeCapabilityResult) -> Unit,
109109 val videoLimit: Long = 0,
110 val selectionLimit: Int = 1,
111 val fileLimit: Long = 0,
110112 )
111113
112114 init {
@@ -181,6 +183,7 @@ internal class CraftNativeCapabilities(
181183 "Files" -> when (method) {
182184 "pickImage" -> pickImage(requestToken, args, completion)
183185 "pickVideo" -> pickVideo(requestToken, args, completion)
186 "pickFile" -> pickFile(requestToken, args, completion)
184187 "download", "write", "remove" -> networkExecutor.execute {
185188 complete(requestToken, files(requestToken, method, args), completion)
186189 }
@@ -545,6 +548,76 @@ internal class CraftNativeCapabilities(
545548 }
546549 }
547550
551 private fun pickFile(requestToken: String, args: JSONArray, completion: (CraftNativeCapabilityResult) -> Unit) {
552 mainHandler.post {
553 val host = activity ?: return@post complete(requestToken, failure("NEEDS_ACTIVITY", "No screen is showing to pick from"), completion)
554 if (pendingFilePick != null) {
555 complete(requestToken, failure("BUSY", "Another file picker is already open"), completion)
556 return@post
557 }
558 val options = args.optJSONObject(0) ?: JSONObject()
559 val types = documentTypes(options.optJSONArray("contentTypes"))
560 if (types == null) {
561 complete(requestToken, failure("INVALID_ARGUMENT", "files.pickFile contentTypes must be MIME types, UTIs, or extensions"), completion)
562 return@post
563 }
564 val selections = options.optInt("maxSelections", 1).coerceIn(1, 20)
565 val limit = options.optLong("maxBytes", 512L * 1024 * 1024).coerceIn(1, 512L * 1024 * 1024)
566 pendingFilePick = PendingFilePick(
567 requestToken,
568 "file",
569 0,
570 1f,
571 completion,
572 selectionLimit = selections,
573 fileLimit = limit,
574 )
575 val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
576 addCategory(Intent.CATEGORY_OPENABLE)
577 type = types.first
578 if (types.second.isNotEmpty()) putExtra(Intent.EXTRA_MIME_TYPES, types.second)
579 putExtra(Intent.EXTRA_ALLOW_MULTIPLE, selections > 1)
580 addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
581 }
582 try { host.startActivityForResult(intent, CRAFT_NATIVE_FILE_PICK_REQUEST) }
583 catch (error: Exception) {
584 pendingFilePick = null
585 complete(requestToken, failure("PICKER_UNAVAILABLE", error.message ?: "File picker could not be opened"), completion)
586 }
587 }
588 }
589
590 /** Android's picker accepts MIME filters; map the common UTI/extension forms used by the shared API. */
591 private fun documentTypes(values: JSONArray?): Pair<String, Array<String>>? {
592 if (values == null || values.length() == 0) return "*/*" to emptyArray()
593 val mime = mutableListOf<String>()
594 for (index in 0 until values.length()) {
595 val raw = values.optString(index).trim().lowercase()
596 if (raw.isBlank()) return null
597 val type = when (raw) {
598 "public.data", "public.item", "public.content" -> "*/*"
599 "public.image" -> "image/*"
600 "public.movie" -> "video/*"
601 "public.audio" -> "audio/*"
602 "public.text" -> "text/*"
603 else -> when {
604 raw.contains('/') -> raw
605 raw == "gpx" -> "application/gpx+xml"
606 raw == "tcx" -> "application/vnd.garmin.tcx+xml"
607 raw == "fit" -> "application/octet-stream"
608 raw == "json" -> "application/json"
609 raw == "csv" -> "text/csv"
610 raw == "txt" -> "text/plain"
611 raw == "pdf" -> "application/pdf"
612 else -> return null
613 }
614 }
615 mime += type
616 }
617 val distinct = mime.distinct()
618 return if (distinct.size == 1) distinct.first() to emptyArray() else "*/*" to distinct.toTypedArray()
619 }
620
548621 fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
549622 if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false
550623 val pending = pendingFilePick ?: return true
@@ -561,6 +634,19 @@ internal class CraftNativeCapabilities(
561634 }
562635 return true
563636 }
637 if (pending.source == "file") {
638 networkExecutor.execute {
639 val uris = buildList {
640 data?.clipData?.let { clips ->
641 for (index in 0 until clips.itemCount) add(clips.getItemAt(index).uri)
642 }
643 data?.data?.let { add(it) }
644 }.distinct().take(pending.selectionLimit)
645 val result = copyDocuments(pending.requestToken, uris, pending.fileLimit)
646 complete(pending.requestToken, result, pending.completion)
647 }
648 return true
649 }
564650 val descriptor = if (pending.source == "camera") {
565651 val bitmap = data?.extras?.get("data") as? Bitmap
566652 bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
@@ -586,6 +672,66 @@ internal class CraftNativeCapabilities(
586672 return writeImage(bitmap, maxPixels, quality, jpegName(name))
587673 }
588674
675 private fun copyDocuments(requestToken: String, uris: List<Uri>, limit: Long): CraftNativeCapabilityResult {
676 if (uris.isEmpty()) return failure("PICKER_FAILED", "No document was selected")
677 val copied = JSONArray()
678 return try {
679 uris.forEach { copied.put(copyDocument(requestToken, it, limit)) }
680 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
681 success(copied)
682 } catch (error: Exception) {
683 for (index in 0 until copied.length()) {
684 copied.optJSONObject(index)?.optString("path")?.let { ownedFile(it)?.parentFile?.deleteRecursively() }
685 }
686 val message = error.message ?: "The selected document could not be read"
687 if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
688 else if (message.startsWith("FILE_TOO_LARGE:")) failure("FILE_TOO_LARGE", message.removePrefix("FILE_TOO_LARGE:").trim())
689 else failure("READ_ERROR", message)
690 }
691 }
692
693 private fun copyDocument(requestToken: String, uri: Uri, limit: Long): JSONObject {
694 var target: File? = null
695 return try {
696 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
697 if (it.moveToFirst()) it.getString(0) else null
698 } ?: "file"
699 val type = context.contentResolver.getType(uri) ?: documentMimeType(name)
700 val destination = File(fileRoot, UUID.randomUUID().toString()).apply { mkdirs() }.resolve(safeFileName(name))
701 target = destination
702 val input = context.contentResolver.openInputStream(uri) ?: throw IllegalArgumentException("The selected document could not be read")
703 var size = 0L
704 input.use { source ->
705 destination.outputStream().use { output ->
706 val buffer = ByteArray(64 * 1024)
707 while (true) {
708 val count = source.read(buffer)
709 if (count < 0) break
710 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: File selection was cancelled")
711 size += count
712 require(size <= limit) { "FILE_TOO_LARGE: Choose a file under ${limit / 1024 / 1024}MB." }
713 output.write(buffer, 0, count)
714 }
715 }
716 }
717 require(size > 0) { "Choose a nonempty file." }
718 descriptor(destination, type)
719 } catch (error: Exception) {
720 target?.parentFile?.deleteRecursively()
721 throw error
722 }
723 }
724
725 private fun documentMimeType(name: String): String = when (name.substringAfterLast('.', "").lowercase()) {
726 "gpx" -> "application/gpx+xml"
727 "tcx" -> "application/vnd.garmin.tcx+xml"
728 "json" -> "application/json"
729 "csv" -> "text/csv"
730 "txt" -> "text/plain"
731 "pdf" -> "application/pdf"
732 else -> "application/octet-stream"
733 }
734
589735 private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
590736 val longest = max(bitmap.width, bitmap.height)
591737 val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f