also looking at this
fix(native): interaction, audio, document and checkpoint gaps stranded after #401
#415
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.
| @@ -1364,8 +1364,10 @@ shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies | ||
| 1364 | 1364 | each selected document into the same app-owned directory, and enforces the |
| 1365 | 1365 | shared 512 MiB default/per-file limit before publishing a descriptor. A |
| 1366 | 1366 | dismissed picker resolves with an empty array; a canceled or oversized copy |
| 1367 | never leaves a partial file behind. Use this for GPX, TCX, FIT, PDF, or other | |
| 1368 | documents that are not photos or videos. | |
| 1367 | never leaves a partial file behind. The same cancellation cleanup applies while | |
| 1368 | Android is decoding a picked image or copying a video, so a request canceled | |
| 1369 | after the picker closes cannot publish or leak an app-owned file. Use this for | |
| 1370 | GPX, TCX, FIT, PDF, or other documents that are not photos or videos. | |
| 1369 | 1371 | |
| 1370 | 1372 | `craft.audio` playback is screen-owned on both hosts. Android requests audio |
| 1371 | 1373 | focus before preparing a player, reports a denied request as `AUDIO_FOCUS_DENIED`, |
| @@ -822,6 +822,11 @@ describe('Craft Android builder', () => { | ||
| 822 | 822 | expect(capabilities).toContain('CANCELLED: File selection was cancelled') |
| 823 | 823 | expect(capabilities).toContain('ownedFile(it)?.parentFile?.deleteRecursively()') |
| 824 | 824 | 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()') | |
| 825 | 830 | }) |
| 826 | 831 | |
| 827 | 832 | it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => { |
| @@ -475,9 +475,10 @@ internal class CraftNativeCapabilities( | ||
| 475 | 475 | } |
| 476 | 476 | |
| 477 | 477 | /** 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 { | |
| 479 | 479 | var target: File? = null |
| 480 | 480 | return try { |
| 481 | if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled") | |
| 481 | 482 | val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { |
| 482 | 483 | if (it.moveToFirst()) it.getString(0) else null |
| 483 | 484 | } ?: "video.mp4" |
| @@ -494,6 +495,7 @@ internal class CraftNativeCapabilities( | ||
| 494 | 495 | while (true) { |
| 495 | 496 | val count = source.read(buffer) |
| 496 | 497 | if (count < 0) break |
| 498 | if (cancelled.contains(requestToken)) throw IllegalStateException("CANCELLED: Video selection was cancelled") | |
| 497 | 499 | size += count |
| 498 | 500 | require(size <= limit) { "Choose a video under ${limit / 1024 / 1024}MB." } |
| 499 | 501 | output.write(buffer, 0, count) |
| @@ -504,7 +506,9 @@ internal class CraftNativeCapabilities( | ||
| 504 | 506 | success(descriptor(destination, type)) |
| 505 | 507 | } catch (error: Exception) { |
| 506 | 508 | 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) | |
| 508 | 512 | } |
| 509 | 513 | } |
| 510 | 514 | |
| @@ -628,7 +632,7 @@ internal class CraftNativeCapabilities( | ||
| 628 | 632 | } |
| 629 | 633 | if (pending.source == "video") { |
| 630 | 634 | networkExecutor.execute { |
| 631 | val result = data?.data?.let { copyVideo(it, pending.videoLimit) } | |
| 635 | val result = data?.data?.let { copyVideo(pending.requestToken, it, pending.videoLimit) } | |
| 632 | 636 | ?: failure("PICKER_FAILED", "The selected video could not be read") |
| 633 | 637 | complete(pending.requestToken, result, pending.completion) |
| 634 | 638 | } |
| @@ -647,18 +651,26 @@ internal class CraftNativeCapabilities( | ||
| 647 | 651 | } |
| 648 | 652 | return true |
| 649 | 653 | } |
| 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 | ) | |
| 656 | 668 | } |
| 657 | complete(pending.requestToken, descriptor?.let(::success) ?: failure("PICKER_FAILED", "The selected image could not be read"), pending.completion) | |
| 658 | 669 | return true |
| 659 | 670 | } |
| 660 | 671 | |
| 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 | |
| 662 | 674 | val name = context.contentResolver.query(uri, arrayOf(android.provider.OpenableColumns.DISPLAY_NAME), null, null, null)?.use { |
| 663 | 675 | if (it.moveToFirst()) it.getString(0) else null |
| 664 | 676 | } ?: "photo" |
| @@ -669,7 +681,7 @@ internal class CraftNativeCapabilities( | ||
| 669 | 681 | while (max(bounds.outWidth / sample, bounds.outHeight / sample) > maxPixels * 2) sample *= 2 |
| 670 | 682 | val options = BitmapFactory.Options().apply { inSampleSize = sample } |
| 671 | 683 | 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) | |
| 673 | 685 | } |
| 674 | 686 | |
| 675 | 687 | private fun copyDocuments(requestToken: String, uris: List<Uri>, limit: Long): CraftNativeCapabilityResult { |
| @@ -732,7 +744,11 @@ internal class CraftNativeCapabilities( | ||
| 732 | 744 | else -> "application/octet-stream" |
| 733 | 745 | } |
| 734 | 746 | |
| 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 | } | |
| 736 | 752 | val longest = max(bitmap.width, bitmap.height) |
| 737 | 753 | val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f |
| 738 | 754 | val output = if (scale < 1f) Bitmap.createScaledBitmap( |
| @@ -749,7 +765,15 @@ internal class CraftNativeCapabilities( | ||
| 749 | 765 | }.getOrDefault(false) |
| 750 | 766 | if (output !== bitmap) output.recycle() |
| 751 | 767 | 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) | |
| 753 | 777 | } |
| 754 | 778 | |
| 755 | 779 | private fun store(bytes: ByteArray, name: String, type: String): CraftNativeCapabilityResult { |