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.
| @@ -1359,6 +1359,14 @@ unchanged. `craft.files.download` resolves a non-2xx response as | ||
| 1359 | 1359 | `{ ok: false, status, body }`; transport failures still reject with the native |
| 1360 | 1360 | network error codes. |
| 1361 | 1361 | |
| 1362 | Both maintained native hosts implement `craft.files.pickFile`. Android maps the | |
| 1363 | shared MIME, UTI, and common extension filters to `ACTION_OPEN_DOCUMENT`, copies | |
| 1364 | each selected document into the same app-owned directory, and enforces the | |
| 1365 | shared 512 MiB default/per-file limit before publishing a descriptor. A | |
| 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. | |
| 1369 | ||
| 1362 | 1370 | The `media` capability (iOS) keeps video and audio on the phone for offline, |
| 1363 | 1371 | in Application Support rather than the temporary directory, left out of |
| 1364 | 1372 | iCloud backups. `craft.media.keep(url, { headers })` downloads the file once |
| @@ -799,6 +799,22 @@ describe('Craft Android builder', () => { | ||
| 799 | 799 | expect(native).toContain('"SOUND_EVENT"') |
| 800 | 800 | }) |
| 801 | 801 | |
| 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 | ||
| 802 | 818 | it('hands native wallet passes to Google Wallet through an opt-in save URL', async () => { |
| 803 | 819 | const output = createTempDir('craft-android-native-wallet-') |
| 804 | 820 | await init({ name: 'Wallet', packageName: 'org.example.wallet', output, runtimeDir: null, config: { renderer: 'native', enableWallet: true } }) |
| @@ -107,6 +107,8 @@ internal class CraftNativeCapabilities( | ||
| 107 | 107 | val quality: Float, |
| 108 | 108 | val completion: (CraftNativeCapabilityResult) -> Unit, |
| 109 | 109 | val videoLimit: Long = 0, |
| 110 | val selectionLimit: Int = 1, | |
| 111 | val fileLimit: Long = 0, | |
| 110 | 112 | ) |
| 111 | 113 | |
| 112 | 114 | init { |
| @@ -181,6 +183,7 @@ internal class CraftNativeCapabilities( | ||
| 181 | 183 | "Files" -> when (method) { |
| 182 | 184 | "pickImage" -> pickImage(requestToken, args, completion) |
| 183 | 185 | "pickVideo" -> pickVideo(requestToken, args, completion) |
| 186 | "pickFile" -> pickFile(requestToken, args, completion) | |
| 184 | 187 | "download", "write", "remove" -> networkExecutor.execute { |
| 185 | 188 | complete(requestToken, files(requestToken, method, args), completion) |
| 186 | 189 | } |
| @@ -545,6 +548,76 @@ internal class CraftNativeCapabilities( | ||
| 545 | 548 | } |
| 546 | 549 | } |
| 547 | 550 | |
| 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 | ||
| 548 | 621 | fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { |
| 549 | 622 | if (requestCode != CRAFT_NATIVE_FILE_PICK_REQUEST) return false |
| 550 | 623 | val pending = pendingFilePick ?: return true |
| @@ -561,6 +634,19 @@ internal class CraftNativeCapabilities( | ||
| 561 | 634 | } |
| 562 | 635 | return true |
| 563 | 636 | } |
| 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 | } | |
| 564 | 650 | val descriptor = if (pending.source == "camera") { |
| 565 | 651 | val bitmap = data?.extras?.get("data") as? Bitmap |
| 566 | 652 | bitmap?.let { writeImage(it, pending.maxPixels, pending.quality, "photo.jpg") } |
| @@ -586,6 +672,66 @@ internal class CraftNativeCapabilities( | ||
| 586 | 672 | return writeImage(bitmap, maxPixels, quality, jpegName(name)) |
| 587 | 673 | } |
| 588 | 674 | |
| 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 | ||
| 589 | 735 | private fun writeImage(bitmap: Bitmap, maxPixels: Int, quality: Float, name: String): JSONObject? { |
| 590 | 736 | val longest = max(bitmap.width, bitmap.height) |
| 591 | 737 | val scale = if (longest > maxPixels) maxPixels.toFloat() / longest else 1f |