diff --git a/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt new file mode 100644 index 00000000..fe82857d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt @@ -0,0 +1,109 @@ +package press.mantra.compose.files + +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import press.mantra.compose.text.ChapterText +import java.io.InputStream + +/** + * The Storage Access Framework, which needs no manifest permission at all. + * + * `OpenDocument` and `OpenMultipleDocuments` are a handoff to the system + * picker: the app never sees the file system, and the grant it comes back with + * covers exactly the documents the reader chose. `READ_EXTERNAL_STORAGE` and + * its API 33 replacements are for apps that browse storage themselves, which + * this does not. + * + * **Both contracts are registered, not just the multiple one.** Registering + * only `OpenMultipleDocuments` and ignoring the flag would work, and would mean + * that the one-file case -- loading a chapter into the add form -- offers a + * picker that lets the reader select six. Asking for one thing and accepting + * six is how a form ends up quietly discarding five of them. + * + * The mime types are deliberately loose. Markdown has no registered type: + * providers report `.md` as `text/markdown`, `text/plain`, `application/octet-stream` + * or nothing at all depending on the provider and the Android version, so a + * narrow filter hides the very files this is for. Filtering on `text/*` plus + * `*/*` and letting `ChapterText.decode` be the judge is both more permissive + * at the picker and stricter about what becomes a chapter. + * + * `title` is unused. `Intent.EXTRA_TITLE` is honoured by `CreateDocument`, where + * it is the suggested filename, and ignored by the open contracts -- the system + * picker titles itself. Nothing is lost by taking the parameter; something would + * be lost by making desktop's title a literal to avoid it. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val context = LocalContext.current + val resolver = remember(context) { context.contentResolver } + + val single = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri -> + onPicked(listOfNotNull(uri?.let { resolver.readPickedFile(it) })) + } + + val multiple = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + onPicked(uris.mapNotNull { resolver.readPickedFile(it) }) + } + + return remember(single, multiple) { + FilePicker { wantsMultiple -> + if (wantsMultiple) multiple.launch(MIME_TYPES) else single.launch(MIME_TYPES) + } + } +} + +private val MIME_TYPES = arrayOf("text/*", "*/*") + +/** + * Reads a picked document, stopping one byte past the cap. + * + * One byte *past*, so an over-size file comes back over-size rather than + * truncated to exactly the limit -- a truncated chapter would decode cleanly + * and be proposed with its ending missing. + * + * A failure here is a document that went away between being picked and being + * read, or a provider that threw; logged and dropped, because the alternative + * is failing the whole selection over one file. + */ +private fun ContentResolver.readPickedFile(uri: Uri): PickedFile? = + runCatching { + val bytes = openInputStream(uri)?.use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) } + ?: return null + PickedFile(name = displayName(uri) ?: uri.lastPathSegment.orEmpty(), bytes = bytes) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $uri", error) + }.getOrNull() + +private fun ContentResolver.displayName(uri: Uri): String? = + runCatching { + query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker" diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 45be57b2..f6e0cc25 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -491,4 +491,8 @@ You already have a chat with %1$s. Checking whether %1$s can receive messages here. %1$s hasn't set up messaging on Mantra yet. Try again later, or ask them to open Mantra. + Load from file + Choose chapter files + That file isn't text. A chapter is markdown or plain text. + That file is too large to be a chapter. diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt new file mode 100644 index 00000000..4640839f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt @@ -0,0 +1,72 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable + +/** + * A file the reader picked, as its name and its bytes. + * + * Bytes rather than a path or an open stream, and that is a decision rather + * than laziness. A path is meaningless on iOS the moment the picker's security + * scope closes, so a path would be a handle that works on two platforms and + * expires on the third. A stream would push the size cap and the decode into + * whichever coroutine happened to read it. Chapters are tens of kilobytes; + * `ChapterText.MAX_FILE_BYTES` bounds the read and the whole file fits in hand. + * + * [name] is for naming the chapter, not for deciding what the file is -- that + * is `ChapterText.decode`'s job, by looking at the bytes. On android the name + * comes from the document provider and can be absent or a lie; it is a default + * a person edits, never a check. + */ +data class PickedFile( + val name: String, + val bytes: ByteArray, +) { + // ByteArray makes the generated equals/hashCode identity-based, which is + // wrong for a data class that will sit in a list in UI state and be + // compared across recompositions. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is PickedFile) return false + return name == other.name && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * name.hashCode() + bytes.contentHashCode() +} + +/** Opens the platform's file chooser. */ +fun interface FilePicker { + fun launch(multiple: Boolean) +} + +/** + * The platform's file chooser, wired to hand [onPicked] what came back. + * + * `expect` rather than a dependency. The obvious alternative is FileKit, which + * covers all three targets from common code -- but this is the only thing in + * the app that needs a dialog, the three calls below are a dozen lines each, + * and the project already keeps four platform seams of exactly this shape + * (`PlatformContext`, `PlatformDatabaseBuilder`, `platformReducedMotion`, + * `platformThemeContrast`). A dependency whose Compose version has to track + * ours is a poor trade for that. + * + * **Nothing is read beyond `ChapterText.MAX_FILE_BYTES`.** Each actual stops + * there, so picking a video by mistake costs a bounded read rather than the + * whole file, and the over-size file still arrives -- with more bytes than the + * cap allows -- so that `decode` can say `TooLarge` instead of the picker + * silently dropping it. + * + * Cancelling hands back an empty list, which is how a caller tells "no files" + * from "files that would not decode". + * + * [title] is the dialog's own title, read from the string catalogue by the + * caller because `stringResource` is composable and the dialog is not. Only + * desktop uses it: android's Storage Access Framework and iOS's document + * browser each title themselves and offer no way to say otherwise. Taken on all + * three anyway, so that the one platform which shows text to the reader gets it + * from the catalogue like every other string in the app. + */ +@Composable +expect fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt new file mode 100644 index 00000000..f992335e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt @@ -0,0 +1,66 @@ +package press.mantra.compose.text + +/** + * What to call a chapter that arrived as a file. + * + * The web app this mirrors has two answers and uses a different one per route: + * the upload names a chapter `file.name` and the paste form names it + * `chunks[0].trim()`. Both are right some of the time. `chapter-01.md` is a + * filename, not a title, and a chapter whose first paragraph is prose has a + * better name in its own text; a chapter whose file is called + * `03 - The Wreck of the Hesperus.md` does not. + * + * So: the filename first, because somebody chose it, and the text only when the + * filename says nothing a reader would want -- and a heading in the text before + * a paragraph of it, because a heading is a title and a paragraph is the + * beginning of the work. + * + * Whatever this returns is a **default in an editable field**, never the final + * answer. A quorum is asked to sign the name along with the chapter, so the + * person uploading has to be able to fix it first. + */ +object ChapterNaming { + + /** Past this, a name is the beginning of a paragraph rather than a title. */ + private const val MAX_DERIVED_LENGTH = 80 + + private val LEADING_ORDINAL = Regex("^[\\s\\-_.0-9]+") + + fun forFile(fileName: String, markdown: String): String = + fromFileName(fileName) ?: fromMarkdown(markdown) ?: "" + + /** + * The filename with its extension and its ordinal prefix removed. + * + * `03 - The Wreck.md` is `The Wreck`; `chapter-01.md` is `chapter`, which is + * a poor title but an honest reflection of a poor filename and is the one + * the person will edit. What is dropped entirely is a name that is *only* + * an ordinal -- `01.md`, `1.txt` -- because "01" as a chapter title is worse + * than falling through to the text. + */ + private fun fromFileName(fileName: String): String? = + fileName.substringBeforeLast('.', fileName) + .replace('_', ' ') + .replace(LEADING_ORDINAL, "") + .trim() + .takeIf { it.isNotEmpty() } + + /** + * The first heading in the text, or failing that its first line. + * + * Reads the markdown rather than [Markdown]'s split, because this runs while + * a file is being looked at rather than while a chapter is being proposed, + * and a heading's `#` has to be stripped either way. + */ + private fun fromMarkdown(markdown: String): String? { + val lines = markdown.lineSequence().map { it.trim() }.filter { it.isNotEmpty() } + + val heading = lines.firstOrNull { it.startsWith("#") } + ?.trimStart('#') + ?.trim() + ?.takeIf { it.isNotEmpty() } + if (heading != null) return heading.take(MAX_DERIVED_LENGTH) + + return lines.firstOrNull()?.take(MAX_DERIVED_LENGTH) + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt new file mode 100644 index 00000000..1f281deb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt @@ -0,0 +1,97 @@ +package press.mantra.compose.text + +/** + * Turning the bytes of a picked file into the markdown a chapter is made of. + * + * Deliberately not `bytes.decodeToString()`, which is what the web app this + * mirrors does (`file.data.toString()` in `server/router/versions/index.js`). + * There, a `.docx` dropped into the form becomes a chapter of zip-header + * garbage that the uploader can go back and delete. Here the next thing that + * happens to this text is that a quorum is asked to sign it, so what cannot be + * read as prose has to be refused at the door rather than corrected afterwards. + * + * All four rules below exist because of that one difference. None of them is + * about being tidy; each is about something that would otherwise be permanent. + */ +object ChapterText { + + /** + * The most bytes a picked file may be. + * + * A crash guard, not the real limit -- what a chapter may actually carry is + * bounded by what one signing proposal can put on the wire, which is far + * tighter. This is here so that picking a video by mistake fails + * on a comparison rather than on a megabyte-counting read, and so the + * platform pickers have a number to stop reading at. + */ + const val MAX_FILE_BYTES: Int = 1 * 1024 * 1024 + + /** How far into a file to look for the NUL that says it is not text. */ + private const val SNIFF_BYTES: Int = 8 * 1024 + + sealed interface Result { + /** Decoded, BOM-stripped, newline-normalised markdown. */ + data class Text(val markdown: String) : Result + + /** Not decodable as UTF-8, or carrying a NUL. */ + data object NotText : Result + + /** Over [MAX_FILE_BYTES]. */ + data object TooLarge : Result + } + + fun decode(bytes: ByteArray): Result { + if (bytes.size > MAX_FILE_BYTES) return Result.TooLarge + + // Sniffed before decoding, not after. A file of UTF-16 text decodes as + // invalid UTF-8 and would be refused anyway, but a file of mostly-ASCII + // binary -- a zip's local header, a PDF's preamble -- can decode + // cleanly and only reads as prose if nobody looks for the NUL. + if (hasNul(bytes)) return Result.NotText + + // Strict, so an invalid sequence is refused rather than replaced. A + // lenient decode substitutes U+FFFD, which is invisible in a text + // field, is signed into the chapter's `original_text` tag, and is then + // part of the group's record of the work for good. + val decoded = runCatching { + bytes.decodeToString(throwOnInvalidSequence = true) + }.getOrElse { return Result.NotText } + + return Result.Text(normalise(decoded)) + } + + /** + * The form of a chapter's markdown that is safe to hash. + * + * Two rewrites, both of which change the text's bytes without changing a + * word of it, which is exactly why they belong here rather than at a call + * site: + * + * **The byte-order mark goes.** A leading `U+FEFF` is invisible in the form + * and in the chapter, and it is part of the first chunk's text -- so the + * same opening paragraph saved by two editors is two different chunk ids, + * and a translation of one does not match the other. + * + * **CRLF becomes LF.** [Markdown]'s paragraph rule already tolerates `\r\n`, + * so splitting is unaffected; what is affected is the `original_text` tag, + * which would carry the carriage returns. The same chapter uploaded from + * Windows and from Linux would be two different events over identical + * prose. A lone `\r` -- classic Mac line endings, and what some converters + * still emit -- is normalised too, for the same reason. + * + * Public because the duplicate check on an upload compares chapters by + * their text, and it has to compare the form that will be signed. + */ + fun normalise(text: String): String = + text.removePrefix("") + .replace("\r\n", "\n") + .replace("\r", "\n") + + private fun hasNul(bytes: ByteArray): Boolean { + val end = minOf(bytes.size, SNIFF_BYTES) + for (index in 0 until end) { + if (bytes[index] == 0.toByte()) return true + } + return false + } +} diff --git a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt index ccc6a166..9465b790 100644 --- a/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt +++ b/composeApp/src/commonMain/kotlin/press/mantra/compose/ui/composable/AddChapterScreen.kt @@ -6,12 +6,15 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.DriveFileRenameOutline +import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -19,10 +22,12 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.TextButton import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -36,6 +41,9 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom import press.mantra.compose.repository.ChatRepository import press.mantra.compose.repository.FrostSigningRepository import press.mantra.compose.repository.MantraRepository +import press.mantra.compose.files.rememberFilePicker +import press.mantra.compose.text.ChapterNaming +import press.mantra.compose.text.ChapterText import press.mantra.compose.text.Markdown import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute @@ -55,8 +63,13 @@ import mantra.composeapp.generated.resources.paste_the_chapter_s_markdown_blank_ import mantra.composeapp.generated.resources.propose_chapter import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter import mantra.composeapp.generated.resources.add_chapter_to +import mantra.composeapp.generated.resources.choose_chapter_files +import mantra.composeapp.generated.resources.load_from_file +import mantra.composeapp.generated.resources.that_file_is_too_large +import mantra.composeapp.generated.resources.that_file_isn_t_text import androidx.compose.material3.SnackbarHost import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState +import press.mantra.compose.ui.composable.widgets.rememberNotifier import press.mantra.compose.ui.theme.readableContent import press.mantra.compose.ui.composable.widgets.ScreenStateTransition import press.mantra.compose.ui.composable.widgets.NavigateBackButton @@ -107,6 +120,42 @@ fun AddChapterScreen( val nameFieldState = rememberTextFieldState() val originalTextFieldState = rememberTextFieldState() + // Read above the handler, not inside it: `rememberNotifier` and + // `stringResource` are composable and a click lambda is not. + val notify = rememberNotifier(rememberCoroutineScope()) + val notText = stringResource(Res.string.that_file_isn_t_text) + val tooLarge = stringResource(Res.string.that_file_is_too_large) + + // One file, into the form that already works. Picking several + // chapters at once is the upload screen's job; here the point is + // to stop a chapter having to be pasted through a keyboard. + val picker = rememberFilePicker( + title = stringResource(Res.string.choose_chapter_files) + ) { files -> + val file = files.firstOrNull() ?: return@rememberFilePicker + + when (val decoded = ChapterText.decode(file.bytes)) { + is ChapterText.Result.Text -> { + originalTextFieldState.setTextAndPlaceCursorAtEnd(decoded.markdown) + // The filename only fills a name nobody has typed. + // Overwriting one that was typed would make loading a + // file a destructive act on a field beside it. + if (nameFieldState.text.isBlank()) { + nameFieldState.setTextAndPlaceCursorAtEnd( + ChapterNaming.forFile(file.name, decoded.markdown) + ) + } + // No snackbar on success. The text arriving in the + // field below is the feedback, and a message over + // the top of it would say less than the thing + // itself. + } + + ChapterText.Result.NotText -> notify(notText) + ChapterText.Result.TooLarge -> notify(tooLarge) + } + } + // Live counts / paragraph (chunk) preview from the markdown text. val originalText = originalTextFieldState.text.toString() val wordCount = Markdown.wordCount(originalText) @@ -221,6 +270,15 @@ fun AddChapterScreen( placeholder = { Text(stringResource(Res.string.eg_chapter_1_the_beginning)) }, ) + TextButton(onClick = { picker.launch(multiple = false) }) { + Icon( + Icons.Default.UploadFile, + contentDescription = "Load the chapter from a file" + ) + Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125)) + Text(stringResource(Res.string.load_from_file)) + } + OutlinedTextField( modifier = Modifier.fillMaxWidth().weight(1f), state = originalTextFieldState, diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt new file mode 100644 index 00000000..6bd643d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt @@ -0,0 +1,68 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ChapterNamingTest { + + private val body = "The first paragraph.\n\nThe second.\n" + + @Test + fun `a filename somebody chose is the name`() { + assertEquals( + "The Wreck of the Hesperus", + ChapterNaming.forFile("The Wreck of the Hesperus.md", body), + ) + } + + @Test + fun `an ordinal prefix is dropped`() { + assertEquals("The Wreck", ChapterNaming.forFile("03 - The Wreck.md", body)) + assertEquals("The Wreck", ChapterNaming.forFile("1. The Wreck.markdown", body)) + } + + @Test + fun `underscores read as spaces`() { + assertEquals("the wreck", ChapterNaming.forFile("the_wreck.txt", body)) + } + + @Test + fun `a filename that is only an ordinal falls through to the text`() { + assertEquals("The first paragraph.", ChapterNaming.forFile("01.md", body)) + } + + @Test + fun `a heading in the text beats the first paragraph`() { + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\n\nThe first paragraph.\n"), + ) + } + + @Test + fun `a heading glued to its paragraph is still found`() { + // The shape mantra.press's TeX converter emits: a heading and the + // paragraph under it separated by a single newline. + assertEquals( + "Chapter one", + ChapterNaming.forFile("1.md", "# Chapter one\nThe first paragraph.\n"), + ) + } + + @Test + fun `a derived name is cut to a title's length`() { + val long = "w ".repeat(200) + + assertEquals(80, ChapterNaming.forFile("1.md", long).length) + } + + @Test + fun `nothing to go on gives an empty name for the form to ask about`() { + assertEquals("", ChapterNaming.forFile("01.md", " \n\n \n")) + } + + @Test + fun `a file with no extension keeps its whole name`() { + assertEquals("CHAPTER ONE", ChapterNaming.forFile("CHAPTER ONE", body)) + } +} diff --git a/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt new file mode 100644 index 00000000..712ee976 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt @@ -0,0 +1,126 @@ +package press.mantra.compose.text + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The four rules [ChapterText.decode] exists for, and the one property that + * makes them worth having: the same prose from two machines has to come out as + * the same bytes, because those bytes become a chunk id a quorum signs. + */ +class ChapterTextTest { + + private fun decoded(bytes: ByteArray): String { + val result = ChapterText.decode(bytes) + assertTrue(result is ChapterText.Result.Text, "expected text, got $result") + return result.markdown + } + + @Test + fun `plain utf8 markdown decodes unchanged`() { + val markdown = "# Chapter one\n\nThe first paragraph.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `multi byte characters survive`() { + // Setswana and a few of the diacritics a translation will actually carry. + val markdown = "Ke a leboga.\n\nŠŌÜ — ôô.\n" + assertEquals(markdown, decoded(markdown.encodeToByteArray())) + } + + @Test + fun `an invalid utf8 sequence is refused rather than replaced`() { + // 0xFF is not a legal lead byte anywhere in UTF-8. + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a replacement character is never introduced`() { + val bytes = byteArrayOf(0x41, 0xFF.toByte(), 0x42) + val lenient = bytes.decodeToString() + + // The point of the rule: a lenient decode succeeds and hides the damage + // inside text that would then be signed. + assertTrue(lenient.contains('�')) + assertEquals(ChapterText.Result.NotText, ChapterText.decode(bytes)) + } + + @Test + fun `a byte order mark is stripped`() { + val withBom = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte()) + + "First paragraph.".encodeToByteArray() + + assertEquals("First paragraph.", decoded(withBom)) + } + + @Test + fun `the same chapter from windows and from linux decodes identically`() { + val crlf = "# Chapter one\r\n\r\nThe first paragraph.\r\nIts second line.\r\n" + val lf = "# Chapter one\n\nThe first paragraph.\nIts second line.\n" + + assertEquals(decoded(lf.encodeToByteArray()), decoded(crlf.encodeToByteArray())) + // And the thing the rule is actually protecting: without it the two are + // different bytes over identical prose, so different chunk ids. + assertNotEquals(crlf, lf) + } + + @Test + fun `a lone carriage return is normalised too`() { + assertEquals("one\ntwo", decoded("one\rtwo".encodeToByteArray())) + } + + @Test + fun `splitting is unaffected by which newlines arrived`() { + val crlf = decoded("One.\r\n\r\nTwo.\r\n\r\nThree.".encodeToByteArray()) + + assertEquals(listOf("One.", "Two.", "Three."), Markdown.splitParagraphs(crlf)) + } + + @Test + fun `a nul byte means the file is not text`() { + val zipHeader = byteArrayOf(0x50, 0x4B, 0x03, 0x04, 0x00, 0x00) + + "docx-ish".encodeToByteArray() + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(zipHeader)) + } + + @Test + fun `a nul past the sniff window is not looked for`() { + // Documents the limit rather than claiming more than it does: the sniff + // reads 8 KiB, and a file whose first 8 KiB are prose reads as prose. + val bytes = "a".repeat(9 * 1024).encodeToByteArray() + byteArrayOf(0x00) + + assertTrue(ChapterText.decode(bytes) is ChapterText.Result.Text) + } + + @Test + fun `utf16 is refused, because it is not supported rather than because it is binary`() { + // FF FE is a UTF-16LE byte order mark and an illegal UTF-8 lead byte, so + // this lands on NotText either way. Asserted so that a future lenient + // decode cannot quietly turn it into a chapter of interleaved NULs. + val utf16 = byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41, 0x00, 0x42, 0x00) + + assertEquals(ChapterText.Result.NotText, ChapterText.decode(utf16)) + } + + @Test + fun `a file at the cap decodes and one byte over does not`() { + val atCap = ByteArray(ChapterText.MAX_FILE_BYTES) { 'a'.code.toByte() } + val overCap = ByteArray(ChapterText.MAX_FILE_BYTES + 1) { 'a'.code.toByte() } + + assertTrue(ChapterText.decode(atCap) is ChapterText.Result.Text) + assertEquals(ChapterText.Result.TooLarge, ChapterText.decode(overCap)) + } + + @Test + fun `an empty file is text, not a failure`() { + // Whether an empty chapter may be proposed is the form's decision; decode + // only says what the bytes were. + assertEquals("", decoded(ByteArray(0))) + } +} diff --git a/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt new file mode 100644 index 00000000..a1412fc4 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt @@ -0,0 +1,135 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import co.touchlab.kermit.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSURL +import platform.Foundation.dataWithContentsOfURL +import platform.UIKit.UIApplication +import platform.UIKit.UIDocumentPickerDelegateProtocol +import platform.UIKit.UIDocumentPickerViewController +import platform.UIKit.UIViewController +import platform.UniformTypeIdentifiers.UTTypeData +import platform.UniformTypeIdentifiers.UTTypePlainText +import platform.darwin.NSObject +import press.mantra.compose.text.ChapterText + +/** + * `UIDocumentPickerViewController`, presented over whatever is on screen. + * + * **Unverified on the host this was written on.** The two ios targets are + * declared only on macos -- see the gate in `composeApp/build.gradle.kts` and + * the reason in `docs/jvm-target.md` -- so this file is not compiled on linux + * and has never been run. It is written to the same shape as the other eight + * `.ios.kt` actuals in this source set and should be read on a mac before it is + * trusted. The alternative, a stub that hands back no files, would be worse: + * it reads as a working picker that the reader's chapters vanish into. + * + * `forOpeningContentTypes` rather than the deprecated `initWithDocumentTypes`, + * which takes UTI strings and was removed from the iOS 14 API surface in all + * but name. + * + * **The types are `UTTypePlainText` and `UTTypeData`**, for the reason android's + * are loose: markdown has no registered UTI, so `.md` resolves to + * `public.plain-text` on some devices and to `public.data` on others depending + * on what else is installed. Offering both means the files this exists for are + * selectable; `ChapterText.decode` is what decides whether they are chapters. + * + * **The delegate is held by the composition, not by the view controller.** UIKit + * holds its delegate weakly, so a delegate created inline is collected before + * the reader finishes choosing and the callback never fires -- the classic + * version of this bug. `remember` is what keeps it alive, and is why it is + * built here rather than inside `launch`. + */ +@OptIn(ExperimentalForeignApi::class) +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val delegate = remember(onPicked) { PickerDelegate(onPicked) } + + return remember(delegate) { + FilePicker { multiple -> + val picker = UIDocumentPickerViewController( + forOpeningContentTypes = listOf(UTTypePlainText, UTTypeData), + asCopy = true, + ) + picker.allowsMultipleSelection = multiple + picker.delegate = delegate + + val presenter = UIApplication.sharedApplication.keyWindow?.rootViewController + if (presenter == null) { + Logger.withTag(TAG).e("No root view controller to present the document picker from") + onPicked(emptyList()) + } else { + presenter.topmost().presentViewController(picker, animated = true, completion = null) + } + } + } +} + +/** + * `asCopy = true` above puts the chosen files in the app's own temporary + * directory, which is what makes reading them afterwards legal without + * `startAccessingSecurityScopedResource`. It is also why [PickedFile] carries + * bytes rather than a url: the copy is the app's to read now and the system's + * to delete whenever it likes. + */ +@OptIn(ExperimentalForeignApi::class) +private class PickerDelegate( + private val onPicked: (List) -> Unit, +) : NSObject(), UIDocumentPickerDelegateProtocol { + + override fun documentPicker( + controller: UIDocumentPickerViewController, + didPickDocumentsAtURLs: List<*>, + ) { + onPicked(didPickDocumentsAtURLs.filterIsInstance().mapNotNull(::readPickedFile)) + } + + override fun documentPickerWasCancelled(controller: UIDocumentPickerViewController) { + onPicked(emptyList()) + } +} + +/** As on the other two platforms: one byte past the cap, so over-size reads as over-size. */ +@OptIn(ExperimentalForeignApi::class) +private fun readPickedFile(url: NSURL): PickedFile? = + runCatching { + val data = NSData.dataWithContentsOfURL(url) ?: return null + PickedFile( + name = url.lastPathComponent ?: "", + bytes = data.toByteArray(limit = ChapterText.MAX_FILE_BYTES + 1), + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked document $url", error) + }.getOrNull() + +@OptIn(ExperimentalForeignApi::class) +private fun NSData.toByteArray(limit: Int): ByteArray { + val size = minOf(length.toInt(), limit) + if (size == 0) return ByteArray(0) + val out = ByteArray(size) + out.usePinned { pinned -> + platform.posix.memcpy(pinned.addressOf(0), bytes, size.toULong()) + } + return out +} + +/** + * The controller actually on screen. + * + * Presenting from the root controller while a sheet is already up throws, and + * this app reaches the add-chapter form through navigation that may have put one + * there. Walking `presentedViewController` is the documented way to find the + * one that can present. + */ +private fun UIViewController.topmost(): UIViewController = + presentedViewController?.topmost() ?: this + +private const val TAG = "FilePicker" diff --git a/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt new file mode 100644 index 00000000..e1e87242 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt @@ -0,0 +1,91 @@ +package press.mantra.compose.files + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import press.mantra.compose.text.ChapterText +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.io.InputStream + +/** + * AWT's `FileDialog`, which is the native chooser on all three desktops. + * + * `JFileChooser` was the alternative and is worse here: it is Swing's own + * dialog, so it looks like Swing rather than like the host, and on macos it is + * notably not the file panel every other application shows. `FileDialog` maps + * to the platform's own in each case. + * + * **It blocks the calling thread**, which on desktop Compose is the UI thread, + * so showing it from a click handler freezes the window until it closes -- and + * reading the files afterwards would freeze it again. Both go to + * [Dispatchers.IO] and the result comes back through [onPicked] on the main + * dispatcher, which is the same shape the android launcher has by construction. + * + * The scope is the composition's. A dialog whose composable has left is a + * dialog whose result has nowhere to go, and cancelling the read is the right + * behaviour rather than a leak to avoid. + */ +@Composable +actual fun rememberFilePicker( + title: String, + onPicked: (List) -> Unit, +): FilePicker { + val scope = rememberCoroutineScope() + + return remember(scope, title, onPicked) { + FilePicker { multiple -> + scope.launch { + val picked = withContext(Dispatchers.IO) { chooseFiles(title, multiple) } + onPicked(picked) + } + } + } +} + +private fun chooseFiles(title: String, multiple: Boolean): List { + // A null parent is allowed and gives an unowned dialog. It is what a Compose + // desktop window gets us without reaching for the AWT window behind it, + // which `ComposeWindow` exposes but only from a `FrameWindowScope` that the + // app's navigation graph does not thread through. + val dialog = object : FileDialog(null as Frame?, title, LOAD) {} + dialog.isMultipleMode = multiple + dialog.isVisible = true + + val chosen = if (multiple) { + dialog.files?.toList().orEmpty() + } else { + listOfNotNull(dialog.directory?.let { dir -> dialog.file?.let { File(dir, it) } }) + } + + return chosen.mapNotNull(::readPickedFile) +} + +/** As on android: one byte past the cap, so an over-size file reads as over-size. */ +private fun readPickedFile(file: File): PickedFile? = + runCatching { + PickedFile( + name = file.name, + bytes = file.inputStream().use { it.readAtMost(ChapterText.MAX_FILE_BYTES + 1) }, + ) + }.onFailure { error -> + Logger.withTag(TAG).e("Could not read the picked file ${file.path}", error) + }.getOrNull() + +private fun InputStream.readAtMost(limit: Int): ByteArray { + val buffer = ByteArray(limit) + var filled = 0 + while (filled < limit) { + val read = read(buffer, filled, limit - filled) + if (read < 0) break + filled += read + } + return buffer.copyOf(filled) +} + +private const val TAG = "FilePicker"