From eb191d945acc08db63b0c54769c65409f698cb44 Mon Sep 17 00:00:00 2001 From: Kgothatso Ngako Date: Fri, 2 Oct 2026 18:29:20 +0200 Subject: [PATCH] feat(chapters): a chapter can be loaded from a file Phase 1 of docs/chapter-uploads.md. The picker and the decode, with one file going into the add-chapter form that already works -- so the platform seam is exercised on its own, before anything knows about several chapters at once. Three actuals rather than FileKit. This is the app's only dialog, the calls are a dozen lines each, and the project already keeps four platform seams of this shape; the library's Compose-version coupling buys nothing here, and the ios target cannot be compiled on a linux host either way. ChapterText.decode is the part that matters. The web app does `file.data.toString()`, where a .docx becomes a chapter of zip-header garbage the uploader can go back and delete; here the next thing that happens to the text is that a quorum signs it. So: strict UTF-8, because a lenient decode hides U+FFFD inside something permanent; a NUL sniff before decoding, because mostly-ASCII binary decodes cleanly; the byte-order mark stripped and CRLF normalised, because both change the bytes without changing a word and the same prose from two machines has to give the same chunk ids. The pickers stop one byte past the cap rather than at it, so an over-size file reads as over-size instead of as a chapter with its ending missing. Android registers both open contracts so asking for one file does not offer a picker that returns six. The dialog title is a parameter read from the catalogue, since stringResource is composable and a FileDialog is not. ChapterNaming came out of the two answers the web app gives on its two routes -- the filename on the upload, the first chunk on the paste form -- both of which are right some of the time. The filename minus extension and ordinal prefix, falling through to a heading and then the first line, because `01.md` is not a chapter title and `03 - The Wreck.md` is. FilePicker.ios.kt is unverified: the ios targets are declared only on macos, so it is not compiled here. Written to the shape of the eight .ios.kt actuals beside it and says so in its KDoc. A stub returning no files would read as a working picker that a reader's chapters vanish into. 22 new tests, all pure; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 --- .../compose/files/FilePicker.android.kt | 109 ++++++++++++++ .../composeResources/values/strings.xml | 4 + .../press/mantra/compose/files/FilePicker.kt | 72 ++++++++++ .../mantra/compose/text/ChapterNaming.kt | 66 +++++++++ .../press/mantra/compose/text/ChapterText.kt | 97 +++++++++++++ .../compose/ui/composable/AddChapterScreen.kt | 58 ++++++++ .../mantra/compose/text/ChapterNamingTest.kt | 68 +++++++++ .../mantra/compose/text/ChapterTextTest.kt | 126 ++++++++++++++++ .../mantra/compose/files/FilePicker.ios.kt | 135 ++++++++++++++++++ .../mantra/compose/files/FilePicker.jvm.kt | 91 ++++++++++++ 10 files changed, 826 insertions(+) create mode 100644 composeApp/src/androidMain/kotlin/press/mantra/compose/files/FilePicker.android.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/files/FilePicker.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterNaming.kt create mode 100644 composeApp/src/commonMain/kotlin/press/mantra/compose/text/ChapterText.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterNamingTest.kt create mode 100644 composeApp/src/commonTest/kotlin/press/mantra/compose/text/ChapterTextTest.kt create mode 100644 composeApp/src/iosMain/kotlin/press/mantra/compose/files/FilePicker.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/press/mantra/compose/files/FilePicker.jvm.kt 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"