Pantrix UI

Picking, dropping and saving files

Why, in Compose Multiplatform, a user's giving the app a file (by choosing it in the platform's picker, or by dragging it over an area and dropping it) cannot be done in common code directly, what the library gives for it, how the file is read, and how it is tested; and the app's having a file it made saved by the user too. The problem is not Pantrix's own: it shows the same way in every Compose Multiplatform project that takes files from its user or gives them files.

Everything said here of Compose was read in the sources of Compose Multiplatform 1.12.1, or seen in a test that runs in this repository, on a device or in a browser; which was seen where is written in sections 7 and 8.

1. The problem

Symptom

Cause

Compose's drop event wraps a type of its own on each platform (Compose Multiplatform 1.12.1, the ui module):

PlatformWhat DragAndDropEvent carriesHow the files are read
AndroidtoAndroidDragEvent(): a DragEventThe content Uris in the items of clipData. Another app's can be read only if Activity.requestDragAndDropPermissions(event) is asked for while the drop lasts.
iOSitems: UIDragItems (@ExperimentalComposeUiApi)Each item's itemProvider loads its file with loadFileRepresentation…. The file it gives is deleted once the loading handler returns: it has to be copied inside it.
DesktopdragData(): a DragData (@ExperimentalComposeUiApi)DragData.FilesList.readFiles(): the addresses of the files on the disk (file:/…).
WebtransferData?.domDataTransferOrNull: the browser's DataTransferfiles. The browser gives them only inside the drop event: they cannot be read later.

For pickers there is nothing common at all; each opens with the platform's own code and gives its result in its own type.

2. The solution: five parts

In foundation (com.pantrixui.foundation.files):

  1. PantrixFile: its name, its size (null where it is not known), its MIME type and suspend fun readBytes(). Nothing is read until readBytes is called. The platform's own type is there too: uri on Android, url on iOS, file on the desktop, domFile on the web. For tests and for files the app makes itself it has a builder from memory: PantrixFile(name, bytes, mimeType).

  2. PantrixFileType: a MIME type (its subtype may be *) and extensions. The ready ones: Any, Images, Videos, Audio, Pdf, Text. A file fits a type when its MIME type fits or its extension is in the list; a file whose type the platform does not say is known by its name too.

  3. FilePicker and rememberFilePicker(): pick(FilePickRequest(accept, multiple)) { files -> … }. It opens the platform's document picker; where only images are asked for (FilePickRequest.imagesOnly), Android and iOS open the photo picker. None asks for a permission. FilePicker is an interface of one method: a test gives its own picker. Where LocalFilePicker gives a picker, rememberFilePicker() returns it in place of the platform's: a test or a preview gives all the areas of a screen the same picker at once.

  4. Modifier.fileDropTarget(enabled, onOver) { files -> … }: turns what is dropped into a list of PantrixFile. On Android it asks for the read permission, on iOS it copies the files into the app's temporary folder, on the web it takes the files while the drop lasts. onOver says true when something that can hold files comes over it, and false when it leaves.

  5. FileSaver and rememberFileSaver(): save(file) { result -> … }. It opens the platform's window for saving, writes the file where the user chooses and says what became of it (FileSaveResult). LocalFileSaver gives a screen a saver of its own.

In the components (com.pantrixui.components.elements.filedrop), PantrixFileDrop (the area that opens the picker when pressed, takes what is dropped on it, and screens what comes by type, size and number) and PantrixFileItem (the row of a file taken) stand on these.

3. With Pantrix

3.1. A form's attachments

var attachments by remember { mutableStateOf(emptyList<PantrixFile>()) }
var refused by remember { mutableStateOf(emptyList<RejectedFile>()) }

PantrixFileDrop(
    onFiles = { files -> attachments += files },
    accept = listOf(PantrixFileType.Pdf, PantrixFileType.Images),
    multiple = true,
    maxSize = 10_000_000,
    onRejected = { refused = it },
    supportingText = if (refused.isEmpty()) "A PDF or an image, 10 MB at most" else "Not taken: ${refused.first().file.name}",
    error = refused.isNotEmpty(),
    tagKey = "attachments"
)
attachments.forEach { file ->
    PantrixFileItem(name = file.name, size = file.size, onRemove = { attachments -= file })
}

3.2. Reading the file

scope.launch {
    val bytes = file.readBytes()          // off the main thread on Android, the desktop and iOS
    upload(file.name, file.mimeType, bytes)
}

Whoever wants to read a large file as a stream uses the platform's own type: contentResolver.openInputStream(file.uri!!) on Android, file.file!!.inputStream() on the desktop.

3.3. Without the area

An app with a design of its own can use foundation's parts alone:

val picker = rememberFilePicker()
var over by remember { mutableStateOf(false) }
Box(
    Modifier
        .fileDropTarget(onOver = { over = it }) { files -> attach(files) }
        .clickable { picker.pick(FilePickRequest(listOf(PantrixFileType.Images))) { attach(it) } }
)

On the web the picker opens only inside a user's press: pick has to be called from a click or a key.

3.4. Having a file saved

Giving the user a file the app made (a theme's file, a report, a chart's picture) is picking's other way round: the user chooses its place and its name, and the app writes it there.

val saver = rememberFileSaver()
PantrixButton(
    text = "Download the theme",
    onClick = {
        saver.save(PantrixFile("Theme.kt", source.encodeToByteArray(), "text/plain")) { result ->
            message = when (result) {
                FileSaveResult.Saved -> "Saved"
                FileSaveResult.Started -> "The download started"
                FileSaveResult.Cancelled -> null
                FileSaveResult.Failed -> "Could not be saved"
            }
        }
    },
    tagKey = "download"
)

4. Without Pantrix

What follows are the shortest forms that can be copied into a project without the library; Pantrix's code (ui/foundation/src/*Main/kotlin/com/pantrixui/foundation/files/) does the same with its tests and its edge cases.

Android, the picker (needs androidx.activity; Compose UI brings it already, but at run time alone: to compile, it has to be written out):

val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { uris -> /* … */ }
launcher.launch(arrayOf("application/pdf", "image/*"))
// Images alone: ActivityResultContracts.PickVisualMedia(), PickVisualMediaRequest(PickVisualMedia.ImageOnly)

Android, dropping:

Modifier.dragAndDropTarget(
    shouldStartDragAndDrop = { event -> event.toAndroidDragEvent().clipDescription?.hasMimeType("*/*") == true },
    target = object : DragAndDropTarget {
        override fun onDrop(event: DragAndDropEvent): Boolean {
            val drag = event.toAndroidDragEvent()
            activity.requestDragAndDropPermissions(drag)   // while the drop lasts
            val uris = (0 until drag.clipData.itemCount).mapNotNull { drag.clipData.getItemAt(it).uri }
            return uris.isNotEmpty()
        }
    }
)

iOS, the picker (asCopy = true: the file is copied into the app's folder, and no security scope is needed):

val picker = UIDocumentPickerViewController(forOpeningContentTypes = listOf(UTTypePDF), asCopy = true)
picker.delegate = delegate              // UIKit holds the delegate weakly: it has to be kept somewhere
LocalUIViewController.current.presentViewController(picker, animated = true, completion = null)

Desktop, the picker:

val dialog = FileDialog(null as Frame?, "Choose a file", FileDialog.LOAD).apply {
    isMultipleMode = true
    filenameFilter = FilenameFilter { _, name -> name.endsWith(".pdf", ignoreCase = true) }
    file = "*.pdf"                       // Windows calls no filter; it reads the name
    isVisible = true                     // waits until the user closes it
}
val files = dialog.files

Web, the picker and dropping:

val input = document.createElement("input") as HTMLInputElement
input.type = "file"; input.accept = "application/pdf,.pdf"; input.multiple = true
input.addEventListener("change", { /* input.files */ })
input.click()                            // inside a user's press

// Dropping: event.transferData?.domDataTransferOrNull?.files, read inside the drop event.

5. What the helper does not do

6. Testing

7. Pitfalls

8. Verification

Tests

WhatWhere
Matching types, the MIME table, the file in memory, the picker's request, the picker LocalFilePicker gives, the saver LocalFileSaver givesui/foundation/src/commonTest/.../files/ (JVM, iOS, JS, Wasm)
Writing where the user chose on the desktop, a place that cannot be writtenFileSaverOnTheDesktopTest
Android's window for a new document: the type, the name offered, the bytes written, the window closed, a place that cannot be written (Robolectric)FileSaverOnAndroidTest
The download's Blob: its type and its number of bytesFilesOnTheWebTest (JS and Wasm)
The copy in the temporary folder, and the answer in a test binaryFileSaverOnIosTest
The desktop's filter, the Windows pattern, the list of dropped filesFilesOnTheDesktopTest
Android's document and photo pickers (Robolectric), the picker LocalFilePicker gives taking their place, the types and addresses of a dropFilesOnAndroidTest
The <input> in a browser, dropping with DataTransfer, reading the bytesFilesOnTheWebTest (JS and Wasm)
Uniform types, copying into the temporary folderFilesOnIosTest
The area: press and keyboard, screening, words, announcement, analytics; the row; colours; stylesui/components/src/commonTest/.../File*Test.kt, MiddleCutTest

Looked at by hand

Not verified

9. Where it is in this repository

10. What is left open

In docs/OPEN_ITEMS.md: reading as a stream, the camera, checking the type while dragging, PantrixText's cutting in the middle working on every platform, dropping on devices.