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+4-2
Changes to docs/guides/native-stx.md
@@ -1364,8 +1364,10 @@ shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies
13641364each selected document into the same app-owned directory, and enforces the
13651365shared 512 MiB default/per-file limit before publishing a descriptor. A
13661366dismissed 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.
1367never leaves a partial file behind. The same cancellation cleanup applies while
1368Android is decoding a picked image or copying a video, so a request canceled
1369after the picker closes cannot publish or leak an app-owned file. Use this for
1370GPX, TCX, FIT, PDF, or other documents that are not photos or videos.
13691371
13701372`craft.audio` playback is screen-owned on both hosts. Android requests audio
13711373focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`,
packages/android/src/index.test.tsmodified+5-0
Changes to packages/android/src/index.test.ts
@@ -822,6 +822,11 @@ describe('Craft Android builder', () => {
822822 expect(capabilities).toContain('CANCELLED: File selection was cancelled')
823823 expect(capabilities).toContain('ownedFile(it)?.parentFile?.deleteRecursively()')
824824 expect(capabilities).toContain('application/vnd.garmin.tcx+xml')
825 expect(capabilities).toContain('copyVideo(pending.requestToken, it, pending.videoLimit)')
826 expect(capabilities).toContain('CANCELLED: Video selection was cancelled')
827 expect(capabilities).toContain('copyUri(pending.requestToken, it, pending.maxPixels, pending.quality)')
828 expect(capabilities).toContain('networkExecutor.execute {')
829 expect(capabilities).toContain('file.parentFile?.deleteRecursively()')
825830 })
826831
827832 it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => {
packages/android/templates/CraftNativeCapabilities.kt.templatemodified+38-14
Changes to packages/android/templates/CraftNativeCapabilities.kt.template
@@ -475,9 +475,10 @@ internal class CraftNativeCapabilities(
475475 }
476476
477477 /** Copy with a bound even when a cloud provider does not report its size. */
478 private fun copyVideo(uri: Uri, limit: Long): CraftNativeCapabilityResult {
478 private fun copyVideo(requestToken: String, uri: Uri, limit: Long): CraftNativeCapabilityResult {
479479 var target: File? = null
480480 return try {
481 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
481482 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
482483 if (it.moveToFirst()) it.getString(0) else null
483484 } ?: "video.mp4"
@@ -494,6 +495,7 @@ internal class CraftNativeCapabilities(
494495 while (true) {
495496 val count = source.read(buffer)
496497 if (count < 0) break
498 if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled")
497499 size += count
498500 require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." }
499501 output.write(buffer, 0, count)
@@ -504,7 +506,9 @@ internal class CraftNativeCapabilities(
504506 success(descriptor(destination, type))
505507 } catch (error: Exception) {
506508 target?.parentFile?.deleteRecursively()
507 failure("READ_ERROR", error.message ?: "The video could not be read")
509 val message = error.message ?: "The video could not be read"
510 if (message.startsWith("CANCELLED:")) failure("CANCELLED", message.removePrefix("CANCELLED:").trim())
511 else failure("READ_ERROR", message)
508512 }
509513 }
510514
@@ -628,7 +632,7 @@ internal class CraftNativeCapabilities(
628632 }
629633 if (pending.source == "video") {
630634 networkExecutor.execute {
631 val result = data?.data?.let { copyVideo(it, pending.videoLimit) }
635 val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) }
632636 ?: failure("PICKER_FAILED", "The selected video could not be read")
633637 complete(pending.requestToken, result, pending.completion)
634638 }
@@ -647,18 +651,26 @@ internal class CraftNativeCapabilities(
647651 }
648652 return true
649653 }
650 val descriptor = if (pending.source == "camera") {
651 val bitmap = data?.extras?.get("data") as? Bitmap
652 bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") }
653 } else {
654 val uri = data?.data
655 uri?.let { copyUri(it, pending.maxPixels, pending.quality) }
654 networkExecutor.execute {
655 val descriptor = if (pending.source == "camera") {
656 val bitmap = data?.extras?.get("data") as? Bitmap
657 bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg", pending.requestToken) }
658 } else {
659 val uri = data?.data
660 uri?.let { copyUri(pending.requestToken, it, pending.maxPixels, pending.quality) }
661 }
662 complete(
663 pending.requestToken,
664 descriptor?.let(::success) ?: if (cancelled.contains(pending.requestToken)) failure("CANCELLED", "Image selection was cancelled")
665 else failure("PICKER_FAILED", "The selected image could not be read"),
666 pending.completion,
667 )
656668 }
657 complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion)
658669 return true
659670 }
660671
661 private fun copyUri(uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
672 private fun copyUri(requestToken: String, uri: Uri, maxPixels: Int, quality: Float): JSONObject? {
673 if (cancelled.contains(requestToken)) return null
662674 val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use {
663675 if (it.moveToFirst()) it.getString(0) else null
664676 } ?: "photo"
@@ -669,7 +681,7 @@ internal class CraftNativeCapabilities(
669681 while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2
670682 val options = BitmapFactory.Options().apply { inSampleSize = sample }
671683 val bitmap = context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, options) } ?: return null
672 return writeImage(bitmap, maxPixels, quality, jpegName(name))
684 return writeImage(bitmap, maxPixels, quality, jpegName(name), requestToken)
673685 }
674686
675687 private fun copyDocuments(requestToken: String, uris: List<Uri>, limit: Long): CraftNativeCapabilityResult {
@@ -732,7 +744,11 @@ internal class CraftNativeCapabilities(
732744 else -> "application/octet-stream"
733745 }
734746
735 private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? {
747 private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String, requestToken: String? = null): JSONObject? {
748 if (requestToken != null && cancelled.contains(requestToken)) {
749 bitmap.recycle()
750 return null
751 }
736752 val longest = max(bitmap.width, bitmap.height)
737753 val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f
738754 val output = if (scale < 1f) Bitmap.createScaledBitmap(
@@ -749,7 +765,15 @@ internal class CraftNativeCapabilities(
749765 }.getOrDefault(false)
750766 if (output !== bitmap) output.recycle()
751767 bitmap.recycle()
752 return if (written) descriptor(file, "image/jpeg").put("width", width).put("height", height) else null
768 if (!written) {
769 file.parentFile?.deleteRecursively()
770 return null
771 }
772 if (requestToken != null && cancelled.contains(requestToken)) {
773 file.parentFile?.deleteRecursively()
774 return null
775 }
776 return descriptor(file, "image/jpeg").put("width", width).put("height", height)
753777 }
754778
755779 private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult {